diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 015c2c3..8a52a48 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: release -f .goreleaser/mac.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} @@ -51,7 +51,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: release -f .goreleaser/linux.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} @@ -70,7 +70,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: release -f .goreleaser/windows.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} @@ -143,7 +143,7 @@ jobs: - name: Build npm binaries with GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: build -f .goreleaser/npm.yml --clean --skip validate env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test-npm-build.yml b/.github/workflows/test-npm-build.yml index 7456a50..4c649f0 100644 --- a/.github/workflows/test-npm-build.yml +++ b/.github/workflows/test-npm-build.yml @@ -26,7 +26,7 @@ jobs: - name: Install GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 install-only: true - name: Run npm build tests diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9e7771..d49dfec 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: release --skip=publish --snapshot -f .goreleaser/mac.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} @@ -56,7 +56,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: release --skip=publish --snapshot -f .goreleaser/linux.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} @@ -75,7 +75,7 @@ jobs: - name: Run GoReleaser uses: goreleaser/goreleaser-action@v5 with: - version: v2.10.2 + version: v2.12.1 args: release --skip=publish --snapshot -f .goreleaser/windows.yml --clean env: GITHUB_TOKEN: ${{ secrets.GORELEASER_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 5d78ed1..bba5dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ __debug_bin node_modules/ .env test-scripts/.install-test/ + +# Temporary OpenAPI spec download (large; do not commit) +.plans/openapi-2025-07-01.json diff --git a/.goreleaser/linux.yml b/.goreleaser/linux.yml index f12d013..4733c33 100644 --- a/.goreleaser/linux.yml +++ b/.goreleaser/linux.yml @@ -52,46 +52,24 @@ nfpms: formats: - deb - rpm -dockers: - - goos: linux - goarch: amd64 - ids: +# dockers_v2 uses buildx to build multi-arch manifests in one step, +# avoiding the "is a manifest list" error from the legacy dockers + docker_manifests flow +dockers_v2: + - ids: - hookdeck-linux - image_templates: - - "hookdeck/hookdeck-cli:{{ .Tag }}-amd64" - - "{{ if not .Prerelease }}hookdeck/hookdeck-cli:latest-amd64{{ end }}" - build_flag_templates: - - "--pull" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.name={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=repository=https://github.com/hookdeck/hookdeck-cli" - - "--label=homepage=https://hookdeck.com" - - "--platform=linux/amd64" - - goos: linux - goarch: arm64 - ids: - hookdeck-linux-arm64 - image_templates: - - "hookdeck/hookdeck-cli:{{ .Tag }}-arm64" - - "{{ if not .Prerelease }}hookdeck/hookdeck-cli:latest-arm64{{ end }}" - build_flag_templates: - - "--pull" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.name={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - - "--label=repository=https://github.com/hookdeck/hookdeck-cli" - - "--label=homepage=https://hookdeck.com" - - "--platform=linux/arm64/v8" -docker_manifests: - - name_template: "hookdeck/hookdeck-cli:{{ .Tag }}" - image_templates: - - "hookdeck/hookdeck-cli:{{ .Tag }}-amd64" - - "hookdeck/hookdeck-cli:{{ .Tag }}-arm64" - - name_template: "hookdeck/hookdeck-cli:latest" - image_templates: - - "hookdeck/hookdeck-cli:latest-amd64" - - "hookdeck/hookdeck-cli:latest-arm64" - skip_push: auto + images: + - "hookdeck/hookdeck-cli" + tags: + - "{{ .Tag }}" + - "{{ if not .Prerelease }}latest{{ end }}" + platforms: + - linux/amd64 + - linux/arm64 + labels: + "org.opencontainers.image.created": "{{.Date}}" + "org.opencontainers.image.name": "{{.ProjectName}}" + "org.opencontainers.image.revision": "{{.FullCommit}}" + "org.opencontainers.image.version": "{{.Version}}" + "repository": "https://github.com/hookdeck/hookdeck-cli" + "homepage": "https://hookdeck.com" diff --git a/.plans/README.md b/.plans/README.md index 33f1919..49fed0a 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -20,11 +20,33 @@ See [`connection-management-status.md`](./connection-management/connection-manag - Connection count command - Connection cloning +## Documentation and Transformation Updates βœ… + +**REFERENCE.md generation:** +- `REFERENCE.md` is now generated from Cobra command metadata via `go run ./tools/generate-reference` +- See `tools/generate-reference/main.go` and `REFERENCE.template.md` + +**Transformation examples:** +- All transformation code examples updated from `module.exports = async (r) => r` to the correct Hookdeck format: `addHandler("transform", (request, context) => { return request; })` +- Applied in: pkg/cmd (create, run, upsert), README.md, REFERENCE.md (via regen), test/acceptance (helpers, transformation_test.go) +- Transformation run API response model aligned with OpenAPI `TransformationExecutorOutput` (uses `request` field for transformed payload) +- CLI adds default `content-type: application/json` when request headers are empty so the transformation engine executes successfully + +**README rebalance:** +- Added Sources and destinations subsection (within Manage connections) with examples and link to [REFERENCE.md#sources](REFERENCE.md#sources) and [REFERENCE.md#destinations](REFERENCE.md#destinations) +- Added Transformations section with examples and link to [REFERENCE.md#transformations](REFERENCE.md#transformations) +- Added Requests, events, and attempts section with examples and links to [REFERENCE.md#requests](REFERENCE.md#requests), [REFERENCE.md#events](REFERENCE.md#events), [REFERENCE.md#attempts](REFERENCE.md#attempts) + ## Active Planning Documents - **[`connection-management-status.md`](./connection-management/connection-management-status.md)** - Current implementation status (98% complete) - **[`resource-management-implementation.md`](./resource-management-implementation.md)** - Overall resource management plan +## Testing and sandbox + +- **Always run tests** when implementing or changing code (`go test ./pkg/...`, and for CLI changes `go test ./test/acceptance/...`). Do not skip tests to avoid failures. +- If tests fail due to **TLS/certificate errors**, **network**, or **sandbox** (e.g. `x509`, `operation not permitted`), **prompt the user** and **re-run with elevated permissions** (e.g. `required_permissions: ["all"]`) so tests can pass. + ## Development Guidelines All CLI development follows the patterns documented in [`AGENTS.md`](../AGENTS.md): @@ -35,4 +57,4 @@ All CLI development follows the patterns documented in [`AGENTS.md`](../AGENTS.m - **Ordered array configurations** - For API arrays with ordering (rules, steps, middleware) - **Idempotent upsert pattern** - For declarative resource management with `--dry-run` support -Design specifications have been consolidated into `AGENTS.md` as general principles with connection management as concrete examples. \ No newline at end of file +Design specifications have been consolidated into `AGENTS.md` as general principles with connection management as concrete examples. diff --git a/.plans/connection-management/connection-management-status.md b/.plans/connection-management/connection-management-status.md index c5f9619..a20cdf8 100644 --- a/.plans/connection-management/connection-management-status.md +++ b/.plans/connection-management/connection-management-status.md @@ -1,5 +1,10 @@ # Connection Management Implementation Status +## Tests and sandbox + +- **Always run tests** when implementing or changing code. Do not skip tests to avoid failures. +- If tests fail due to **TLS/certificate errors**, **network**, or **sandbox** (e.g. `x509`, `operation not permitted`), **prompt the user** and **re-run with elevated permissions** (e.g. `required_permissions: ["all"]`) so tests can pass. + ## Executive Summary Connection management for the Hookdeck CLI is **98% complete and production-ready**. All core CRUD operations, lifecycle management, comprehensive authentication, rule configuration, and rate limiting have been fully implemented. The remaining 2% consists of optional enhancements (bulk operations, connection count, cloning) that are low priority. diff --git a/.plans/resource-management-implementation.md b/.plans/resource-management-implementation.md index f2c5129..700f92b 100644 --- a/.plans/resource-management-implementation.md +++ b/.plans/resource-management-implementation.md @@ -32,14 +32,23 @@ - [ ] `destination update` - Critical for URL changes - [ ] `destination delete` - Clean up unused +### βœ… Recent (February 2026) +- **Transformation examples** - All examples updated to `addHandler("transform", ...)` format (README, REFERENCE, pkg/cmd, tests) +- **Transformation run** - API response model fixed to match OpenAPI; CLI displays transformed output; default content-type for empty headers + ### πŸ“‹ Planned -- **Transformation Management** (Priority 2 - Week 2) +- **Transformation Management** (Priority 2 - Week 2) - CRUD already present; examples and run output now correct - **Project Management Extensions** (Priority 3 - Week 3) - **Advanced Features** (Future) --- +## Testing and sandbox + +- **Always run tests** when implementing or changing code. Do not skip tests to avoid failures. +- If tests fail due to **TLS/certificate errors**, **network**, or **sandbox** (e.g. `x509`, `operation not permitted`), **prompt the user** and **re-run with elevated permissions** (e.g. `required_permissions: ["all"]`) so tests can pass. + ## Background The Hookdeck CLI currently supports limited commands in `@pkg/cmd` with basic project management. This plan outlines implementing comprehensive resource management for projects, connections, sources, destinations, and transformations using the Hookdeck API (https://api.hookdeck.com/2025-07-01/openapi). @@ -56,6 +65,7 @@ The Hookdeck CLI currently supports limited commands in `@pkg/cmd` with basic pr - **Idempotent operations** - `upsert` commands with `--dry-run` support for declarative management - **Type-driven validation** - Progressive validation based on `--type` parameters - **JSON fallback** - Complex configurations via `--rules`, `--rules-file`, `--config`, `--config-file` +- **Plural alias for resource commands** - Every resource command group uses singular as primary `Use` and **must** have the plural as an alias (e.g. `source`/`sources`, `connection`/`connections`, `project`/`projects`). See AGENTS.md Β§ Resource command naming and plural alias. All CLI commands must follow these established patterns for consistency across the codebase. @@ -66,7 +76,7 @@ All CLI commands must follow these established patterns for consistency across t 3. **Add source management** - Manage webhook sources with various provider types 4. **Add destination management** - Manage HTTP, CLI, and Mock API destinations 5. **Add transformation management** - Manage JavaScript code transformations -6. **Create reference documentation** - Comprehensive `REFERENCE.md` with examples +6. ~~**Create reference documentation**~~ - βœ… REFERENCE.md generated via `go run ./tools/generate-reference` from Cobra metadata 7. **Maintain consistency** - Follow existing CLI patterns and architecture ## Success Criteria @@ -398,9 +408,10 @@ func validateSourceType(sourceType string, flags *sourceCreateFlags) error { ### Phase 4: Documentation and Examples -#### Task 4.1: Create Reference Documentation -**Files to create:** -- `REFERENCE.md` - Comprehensive CLI reference +#### Task 4.1: Create Reference Documentation βœ… +**Files:** `REFERENCE.md` (generated), `tools/generate-reference/main.go`, `REFERENCE.template.md` + +REFERENCE.md is generated from Cobra command metadata. Run `go run ./tools/generate-reference` after changing commands/flags. README rebalanced with Sources/destinations, Transformations, and Requests/events/attempts sections, each linking to REFERENCE.md subsections. **Content Structure:** ```markdown @@ -464,6 +475,8 @@ cmd.Example = ` # List all sources ### Phase 5: Testing and Validation +**CLI conventions checklist (all phases):** When adding or reviewing a resource command group, ensure it has a **plural alias** (e.g. `source`/`sources`, `connection`/`connections`, `project`/`projects`). See AGENTS.md Β§ Resource command naming and plural alias. + #### Task 5.1: Add Command Tests **Files to create:** - `pkg/cmd/*_test.go` - Unit tests for all commands diff --git a/AGENTS.md b/AGENTS.md index 5e25f68..035e5b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,8 @@ This repository contains the Hookdeck CLI, a Go-based command-line tool for mana ### Key Files - `https://api.hookdeck.com/2025-07-01/openapi` - API specification (source of truth for all API interactions) +- `pkg/cmd/sources/` - Fetches and caches the OpenAPI spec for source type enum and auth rules; use for validation and help in source and connection management +- `pkg/cmd/helptext.go` - Shared Short/Long help for resource commands (sources, connections); use when adding or editing command help to avoid duplication - `.plans/` - Implementation plans and architectural decisions - `AGENTS.md` - This file (guidelines for AI agents) @@ -116,6 +118,20 @@ hookdeck connection create \ --destination-url "https://api.example.com/webhooks" ``` +### Resource command naming and plural alias +For every **resource command group** (a top-level or gateway subcommand that manages a single resource type), use the **singular** as the primary `Use` and **always add the plural as an alias**. Many users type the plural (e.g. `projects`, `connections`, `sources`); supporting both keeps the CLI discoverable and consistent. + +- **Primary:** singular (`source`, `connection`, `project`) +- **Alias:** plural (`sources`, `connections`, `projects`) + +Example in Cobra: +```go +Use: "source", +Aliases: []string{"sources"}, +``` + +When adding a new resource command group (e.g. destination, transformation), add the plural alias at the same time. Existing groups: `connection`/`connections`, `project`/`projects`, `source`/`sources`. + ## 3. Conditional Validation Implementation When `--type` parameters control other valid parameters, implement progressive validation: @@ -157,11 +173,26 @@ func validateTypeA(flags map[string]interface{}) error { } ``` +### Validation Philosophy +- **Prefer API feedback.** Let the API return errors for business rules and schema (invalid type, missing auth, bad payload). Avoid duplicating API validation client-side unless it clearly improves UX or you can use the cached OpenAPI spec. +- **Client-side validation is for:** (1) clear UX wins (e.g. Cobra required flags, "no updates specified" when update is run with no flags), and (2) validation driven by the **cached OpenAPI spec** (e.g. source/connection type enum and required auth from `FetchSourceTypes()`). When the cache is used, validate type and type-specific required flags; if the spec cannot be fetched, warn and let the API validate. +- **Do not** add ad-hoc client-side schema validation that duplicates or drifts from the API. When in doubt, send the request and surface the API error. + +### Create vs update request shapes +- **Check the OpenAPI spec** for required request-body fields per operation: create/upsert often require an identifier (e.g. `name`); update (PUT by id) often has **no** required body fields. +- When semantics differ, use **separate request types** (e.g. `SourceCreateRequest` vs `SourceUpdateRequest`): create/upsert structs send required fields; update structs use `omitempty` on all fields so only changed fields are sent. Never send empty strings for "unchanged" fields on update. +- For update commands, if the user supplies no update flags, **fail in the CLI** with a clear message (e.g. "no updates specified (set at least one of …)") instead of sending an empty body. + +### Using the cached OpenAPI spec +- Source type enum and auth rules are available via **`pkg/cmd/sources.FetchSourceTypes()`** (fetches from the API OpenAPI URL, caches under temp with TTL). Use it for **source management** (e.g. `source create`, `source upsert`) and **connection management** (e.g. `connection create` inline source) to validate `--type` and type-specific required auth flags. +- If `FetchSourceTypes()` fails (network, parse), **warn and continue**β€”do not block the command; let the API validate. If the given type is not in the cached enum, let the API validate. +- Prefer this over hardcoding type lists or required-auth rules so the CLI stays aligned with the API. + ### Validation Layers (in order) 1. **Flag parsing validation** - Ensure flag values are correctly typed -2. **Type-specific validation** - Validate based on `--type` parameter +2. **Type-specific validation** - Validate based on `--type` parameter (use cached spec when available) 3. **Cross-parameter validation** - Check relationships between parameters -4. **API schema validation** - Final validation against OpenAPI constraints +4. **API** - Final authority; surface API errors to the user ### Help System Integration Provide dynamic help text based on selected type: @@ -212,6 +243,12 @@ go test ./pkg/cmd/ go test -race ./... ``` +### Sandbox and permissions + +Commands that need network (e.g. `git push`, `gh pr create`, `npm install`) or full system access often fail in the sandbox with **TLS/certificate errors** (e.g. `x509: OSStatus -26276`) or auth prompts. These are sandbox restrictions, not broken tooling. + +**Prefer requesting elevated permissions** (e.g. `required_permissions: ["all"]` or `["network"]`) and asking the user to approve so the agent can retry the command. Do not default to prompting the user to run commands themselves when elevation is available. Only fall back to copy-pasteable commands when elevated permissions are not an option. + ### Linting and Formatting ```bash # Format code @@ -233,8 +270,33 @@ go run cmd/hookdeck/main.go go run cmd/hookdeck/main.go login --help ``` +### Sandbox and command execution +- **Always run tests** when changing code: unit tests (`go test ./pkg/...`) and, for CLI-facing changes, acceptance tests (`go test ./test/acceptance/...`). Do not skip tests to avoid failures. +- When running commands (build, test, acceptance tests), if you see **TLS/certificate errors** (e.g. `x509: certificate verify failed`, `tls: failed to verify certificate`), **permission errors** (e.g. `operation not permitted` when writing to the Go module cache), or similar failures that look environment-related, the command is likely running inside a **sandbox**. **Prompt the user** and **re-run the command with elevated permissions** (e.g. `required_permissions: ["network"]` for tests that need API access, or `["all"]` to disable the sandbox) so the operation can succeed. Do not treat a build or test as passed if stderr shows these errors, even when the process exit code is 0. + ## 6. Documentation Standards +### Command help text (Short and Long) + +Use the shared helpers in **`pkg/cmd/helptext.go`** for resource commands so Short and the common part of Long are defined once and stay consistent across sources, connections, and any future resources. + +- **Resource constants:** `ResourceSource`, `ResourceConnection` (singular form, e.g. "source", "connection"). +- **Short (one line):** Use `ShortGet(resource)`, `ShortList(resource)`, `ShortDelete(resource)`, `ShortDisable(resource)`, `ShortEnable(resource)`, `ShortUpdate(resource)`, `ShortCreate(resource)`, `ShortUpsert(resource)` instead of literal strings. +- **Long (intro paragraph):** Use `LongGetIntro(resource)`, `LongUpdateIntro(resource)`, `LongDeleteIntro(resource)`, `LongDisableIntro(resource)`, `LongEnableIntro(resource)`, `LongUpsertIntro(resource)` for the first sentence/paragraph, then append command-specific content (e.g. Examples, extra paragraphs) in the command file. + +When adding a **new resource** that follows the same CRUD/get/list/delete/disable/enable/create/upsert pattern, add a new constant (e.g. `ResourceDestination`) and use the same Short/Long intro helpers; extend `helptext.go` only when you need a new *pattern* (e.g. a new verb), not for each resource. Keep command-specific wording (e.g. "Create a connection between a source and destination", list filter descriptions) in the command file. + +### Cobra Example and output for website docs + +CLI content is generated for the website via `tools/generate-reference`. The generator emits usage, **arguments** (if `Annotations["cli.arguments"]` is set), flags, and the command's `Example` field. Human-injected content in the website (output examples, scenario walkthroughs, behavioral notes) is **required**β€”it improves docs beyond what generation provides. + +- **Arguments:** For commands with positional args, set `c.Annotations["cli.arguments"]` to a JSON array of `{name, type, description, required}`. The generator emits an Arguments table before Flags. See `pkg/cmd/listen.go` for an example. +- **Example (simple output):** Add to Cobra `Example` when the output is short, generic, and helps users verify success (e.g. create, list, get). Keep it representative of actual CLI output; truncate long TUI with `...` if needed. +- **Example (complex output):** For long or scenario-specific output (e.g. dry-run, multi-step flows), add it in the website mdoc as human content immediately after the command's generated section. Split GENERATE blocks so the human section sits next to that command (see website AGENTS.md). +- **Heading level for human sections:** Use `####` (h4) for human-injected sections (e.g. dry-run output, scenario addenda) so they do not appear in the sidebar TOC. Use `###` (h3) or higher only for sections that should appear in the TOC. +- **Behavioral notes:** Add short clarifications to `Long` when they apply everywhere (e.g. "Use `--dry-run` to preview changes"; "`--disabled` shows all connections, not just disabled ones"). Longer narrative goes in the website. +- **Keep in sync:** When CLI output (success messages, TUI, error text) changes, update the website examples (CI, listen, etc.) or Cobra Example so generated docs stay accurate. + ### CLI Documentation - **REFERENCE.md**: Must include all commands with examples - Use status indicators: βœ… Current vs 🚧 Planned @@ -286,6 +348,9 @@ if apiErr, ok := err.(*hookdeck.APIError); ok { ## 9. Testing Guidelines +- **Always run tests** when changing code. Run unit tests (`go test ./pkg/...`) and, for CLI-facing changes, acceptance tests (`go test ./test/acceptance/...`). If tests fail due to TLS/network/sandbox (e.g. `x509`, `operation not permitted`), prompt the user and re-run with elevated permissions (e.g. `required_permissions: ["all"]`) so tests can pass. +- **Create tests for new functionality.** Add unit tests for validation and business logic; add acceptance tests for flows that use the CLI as a user or agent would (success and failure paths). Acceptance tests must pass or failβ€”no skipping to avoid failures. + ### Unit Testing - Test validation logic thoroughly - Mock API calls for command tests diff --git a/Dockerfile b/Dockerfile index c98b99c..d20fed6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ FROM alpine RUN apk update && apk upgrade && \ apk add --no-cache ca-certificates -COPY hookdeck /bin/hookdeck +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/hookdeck /bin/hookdeck ENTRYPOINT ["/bin/hookdeck"] diff --git a/README.md b/README.md index 638a361..e327ff5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Although it uses a different approach and philosophy, it's a replacement for ngr Hookdeck for development is completely free, and we monetize the platform with our production offering. -For a complete reference, see the [CLI reference](https://hookdeck.com/docs/cli?ref=github-hookdeck-cli). +For a complete reference of all commands and flags, see [REFERENCE.md](REFERENCE.md). https://github.com/user-attachments/assets/7a333c5b-e4cb-45bb-8570-29fafd137bd2 @@ -456,25 +456,120 @@ Events β€’ [↑↓] Navigate ───────────────── > βœ“ Last event succeeded with status 200 | [r] Retry β€’ [o] Open in dashboard β€’ [d] Show data ``` +### Event Gateway + +The `hookdeck gateway` command provides full access to Hookdeck Event Gateway resources. Use these subcommands to manage infrastructure and inspect events: + +| Command group | Description | +|---------------|-------------| +| `hookdeck gateway connection` | Create and manage connections between sources and destinations | +| `hookdeck gateway source` | Manage inbound webhook sources | +| `hookdeck gateway destination` | Manage destinations (HTTP endpoints, CLI, etc.) | +| `hookdeck gateway event` | List, get, retry, cancel, or mute events (processed deliveries) | +| `hookdeck gateway request` | List, get, and retry requests (raw inbound webhooks) | +| `hookdeck gateway attempt` | List and get delivery attempts | +| `hookdeck gateway transformation` | Create and manage JavaScript transformations | + +**Examples:** + +```sh +# List sources and destinations +hookdeck gateway source list +hookdeck gateway destination list + +# List events (processed deliveries) and requests (raw inbound webhooks) +hookdeck gateway event list --status FAILED +hookdeck gateway request list --source-id src_abc123 + +# List attempts for an event +hookdeck gateway attempt list --event-id evt_abc123 + +# Create a transformation and test-run it +hookdeck gateway transformation create --name my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" +hookdeck gateway transformation run --code "addHandler(\"transform\", (request, context) => { return request; });" --request '{"headers":{}}' +``` + +For complete command and flag reference, see [REFERENCE.md](REFERENCE.md). + ### Manage connections -Create and manage webhook connections between sources and destinations with inline resource creation, authentication, processing rules, and lifecycle management. For detailed examples with authentication, filters, retry rules, and rate limiting, see the complete [connection management](#manage-connections) section below. +Create and manage webhook connections between sources and destinations with inline resource creation, authentication, processing rules, and lifecycle management. Use `hookdeck gateway connection` (or the backward-compatible alias `hookdeck connection`). For detailed examples with authentication, filters, retry rules, and rate limiting, see the complete [connection management](#manage-connections) section below. ```sh -hookdeck connection [command] +hookdeck gateway connection [command] # Available commands -hookdeck connection list # List all connections -hookdeck connection get # Get connection details -hookdeck connection create # Create a new connection -hookdeck connection upsert # Create or update a connection (idempotent) -hookdeck connection delete # Delete a connection -hookdeck connection enable # Enable a connection -hookdeck connection disable # Disable a connection -hookdeck connection pause # Pause a connection -hookdeck connection unpause # Unpause a connection +hookdeck gateway connection list # List all connections +hookdeck gateway connection get # Get connection details +hookdeck gateway connection create # Create a new connection +hookdeck gateway connection upsert # Create or update a connection (idempotent) +hookdeck gateway connection update # Update a connection +hookdeck gateway connection delete # Delete a connection +hookdeck gateway connection enable # Enable a connection +hookdeck gateway connection disable # Disable a connection +hookdeck gateway connection pause # Pause a connection +hookdeck gateway connection unpause # Unpause a connection +``` + +#### Sources and destinations + +You can manage sources and destinations independently, not only inline when creating connections. Create reusable sources (e.g. Stripe, GitHub) and destinations (HTTP endpoints) that multiple connections can reference. + +```sh +# List and inspect sources and destinations +hookdeck gateway source list +hookdeck gateway source get src_abc123 + +hookdeck gateway destination list +hookdeck gateway destination get dst_abc123 + +# Create a standalone destination +hookdeck gateway destination create --name "my-api" --type HTTP --url "https://api.example.com/webhooks" +``` + +See [Sources](REFERENCE.md#sources) and [Destinations](REFERENCE.md#destinations) in REFERENCE.md. + +### Transformations + +Transformations are JavaScript modules that modify requests before delivery. They are attached to connections and can add headers, transform the body, or filter events. Create, test, and manage transformations with `hookdeck gateway transformation`: + +```sh +# Create a transformation +hookdeck gateway transformation create --name my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" + +# Test run transformation code (see transformed output) +hookdeck gateway transformation run --code "addHandler(\"transform\", (request, context) => { return request; });" --request '{"headers":{}}' + +# List and use with connections (--transformation-name when creating connections) +hookdeck gateway transformation list ``` +See [Transformations](REFERENCE.md#transformations) in REFERENCE.md. + +### Requests, events, and attempts + +Webhooks flow through Hookdeck as **requests** (raw inbound), then **events** (processed, routed), then **attempts** (delivery tries). Use these commands to inspect, filter, and retry: + +```sh +# List requests (raw inbound webhooks) and filter by source +hookdeck gateway request list --source-id src_abc123 +hookdeck gateway request get req_abc123 + +# List events (processed deliveries) by status +hookdeck gateway event list --status FAILED +hookdeck gateway event list --status PENDING +hookdeck gateway event get evt_abc123 + +# Retry a failed event or request +hookdeck gateway event retry evt_abc123 +hookdeck gateway request retry req_abc123 + +# List attempts (individual delivery tries) for an event +hookdeck gateway attempt list --event-id evt_abc123 +``` + +See [Requests](REFERENCE.md#requests), [Events](REFERENCE.md#events), and [Attempts](REFERENCE.md#attempts) in REFERENCE.md. + ### Manage active project If you are a part of multiple projects, you can switch between them using our project management commands. @@ -642,7 +737,7 @@ Create a new connection between a source and destination. You can create the sou ```sh # Basic connection with inline source and destination -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "github-repo" \ --source-type GITHUB \ --destination-name "ci-system" \ @@ -656,9 +751,9 @@ Source URL: https://hkdk.events/src_xyz789 Destination: ci-system (dst_def456) # Using existing source and destination -$ hookdeck connection create \ - --source "existing-source-name" \ - --destination "existing-dest-name" \ +$ hookdeck gateway connection create \ + --source-id src_existing123 \ + --destination-id dst_existing456 \ --name "new-connection" \ --description "Connects existing resources" ``` @@ -669,7 +764,7 @@ Verify webhooks from providers like Stripe, GitHub, or Shopify by adding source ```sh # Stripe webhook signature verification -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "stripe-prod" \ --source-type STRIPE \ --source-webhook-secret "whsec_abc123xyz" \ @@ -678,7 +773,7 @@ $ hookdeck connection create \ --destination-url "https://api.example.com/webhooks/stripe" # GitHub webhook signature verification -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "github-webhooks" \ --source-type GITHUB \ --source-webhook-secret "ghp_secret123" \ @@ -693,7 +788,7 @@ Secure your destination endpoint with bearer tokens, API keys, or basic authenti ```sh # Destination with bearer token -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "webhook-source" \ --source-type HTTP \ --destination-name "secure-api" \ @@ -702,7 +797,7 @@ $ hookdeck connection create \ --destination-bearer-token "bearer_token_xyz" # Destination with API key -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "webhook-source" \ --source-type HTTP \ --destination-name "api-endpoint" \ @@ -711,7 +806,7 @@ $ hookdeck connection create \ --destination-api-key "your_api_key" # Destination with custom headers -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "webhook-source" \ --source-type HTTP \ --destination-name "custom-api" \ @@ -725,7 +820,7 @@ Add automatic retry logic with exponential or linear backoff: ```sh # Exponential backoff retry strategy -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "payment-webhooks" \ --source-type STRIPE \ --destination-name "payment-api" \ @@ -742,7 +837,7 @@ Filter events based on request body, headers, path, or query parameters: ```sh # Filter by event type in body -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "events" \ --source-type HTTP \ --destination-name "processor" \ @@ -751,7 +846,7 @@ $ hookdeck connection create \ --rule-filter-body '{"event_type":"payment.succeeded"}' # Combined filtering -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "shopify-webhooks" \ --source-type SHOPIFY \ --destination-name "order-processor" \ @@ -768,7 +863,7 @@ Control the rate of event delivery to your destination: ```sh # Limit to 100 requests per minute -$ hookdeck connection create \ +$ hookdeck gateway connection create \ --source-name "high-volume-source" \ --source-type HTTP \ --destination-name "rate-limited-api" \ @@ -784,7 +879,7 @@ Create or update connections idempotently based on connection name - perfect for ```sh # Create if doesn't exist, update if it does -$ hookdeck connection upsert my-connection \ +$ hookdeck gateway connection upsert my-connection \ --source-name "stripe-prod" \ --source-type STRIPE \ --destination-name "api-prod" \ @@ -792,12 +887,12 @@ $ hookdeck connection upsert my-connection \ --destination-url "https://api.example.com" # Partial update of existing connection -$ hookdeck connection upsert my-connection \ +$ hookdeck gateway connection upsert my-connection \ --description "Updated description" \ --rule-retry-count 5 # Preview changes without applying (dry-run) -$ hookdeck connection upsert my-connection \ +$ hookdeck gateway connection upsert my-connection \ --description "New description" \ --dry-run @@ -812,20 +907,20 @@ View all connections with flexible filtering options: ```sh # List all connections -$ hookdeck connection list +$ hookdeck gateway connection list # Filter by source or destination -$ hookdeck connection list --source src_abc123 -$ hookdeck connection list --destination des_xyz789 +$ hookdeck gateway connection list --source-id src_abc123 +$ hookdeck gateway connection list --destination-id dst_def456 # Filter by name pattern -$ hookdeck connection list --name "production-*" +$ hookdeck gateway connection list --name "production-*" # Include disabled connections -$ hookdeck connection list --disabled +$ hookdeck gateway connection list --disabled # Output as JSON -$ hookdeck connection list --output json +$ hookdeck gateway connection list --output json ``` #### Get connection details @@ -834,16 +929,16 @@ View detailed information about a specific connection: ```sh # Get by ID -$ hookdeck connection get conn_123abc +$ hookdeck gateway connection get conn_123abc # Get by name -$ hookdeck connection get "my-connection" +$ hookdeck gateway connection get "my-connection" # Get as JSON -$ hookdeck connection get conn_123abc --output json +$ hookdeck gateway connection get conn_123abc --output json # Include destination authentication credentials -$ hookdeck connection get conn_123abc --include-destination-auth --output json +$ hookdeck gateway connection get conn_123abc --include-destination-auth --output json ``` #### Connection lifecycle management @@ -852,16 +947,16 @@ Control connection state and event processing behavior: ```sh # Disable a connection (stops receiving events entirely) -$ hookdeck connection disable conn_123abc +$ hookdeck gateway connection disable conn_123abc # Enable a disabled connection -$ hookdeck connection enable conn_123abc +$ hookdeck gateway connection enable conn_123abc # Pause a connection (queues events without forwarding) -$ hookdeck connection pause conn_123abc +$ hookdeck gateway connection pause conn_123abc # Resume a paused connection -$ hookdeck connection unpause conn_123abc +$ hookdeck gateway connection unpause conn_123abc ``` **State differences:** @@ -874,16 +969,16 @@ Delete a connection permanently: ```sh # Delete with confirmation prompt -$ hookdeck connection delete conn_123abc +$ hookdeck gateway connection delete conn_123abc # Delete by name -$ hookdeck connection delete "my-connection" +$ hookdeck gateway connection delete "my-connection" # Skip confirmation -$ hookdeck connection delete conn_123abc --force +$ hookdeck gateway connection delete conn_123abc --force ``` -For complete flag documentation and all examples, see the [CLI reference](https://hookdeck.com/docs/cli?ref=github-hookdeck-cli). +For complete flag documentation and all examples, see [REFERENCE.md](REFERENCE.md). ## Configuration files @@ -1010,6 +1105,20 @@ Running from source: go run main.go ``` +### Generating REFERENCE.md + +The [REFERENCE.md](REFERENCE.md) file is generated from Cobra command metadata. After changing commands, flags, or help text, regenerate it: + +```sh +go run ./tools/generate-reference --input REFERENCE.template.md --output REFERENCE.md +``` + +To validate that REFERENCE.md is up to date (useful in CI): + +```sh +go run ./tools/generate-reference --input REFERENCE.template.md --output REFERENCE.md --check +``` + Build from source by running: ```sh diff --git a/REFERENCE.md b/REFERENCE.md index aab4c42..0ca6a42 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -1,2206 +1,1762 @@ # Hookdeck CLI Reference -> [!IMPORTANT] -> This document is a work in progress and is not 100% accurate. + The Hookdeck CLI provides comprehensive webhook infrastructure management including authentication, project management, resource management, event and attempt querying, and local development tools. This reference covers all available commands and their usage. ## Table of Contents -### Current Functionality βœ… + - [Global Options](#global-options) - [Authentication](#authentication) -- [Projects](#projects) (list and use only) +- [Projects](#projects) - [Local Development](#local-development) -- [CI/CD Integration](#cicd-integration) -- [Utilities](#utilities) -- [Current Limitations](#current-limitations) - -### Planned Functionality 🚧 -- [Advanced Project Management](#advanced-project-management) +- [Gateway](#gateway) +- [Connections](#connections) - [Sources](#sources) - [Destinations](#destinations) -- [Connections](#connections) - [Transformations](#transformations) - [Events](#events) -- [Issue Triggers](#issue-triggers) -- [Attempts](#attempts) -- [Bookmarks](#bookmarks) -- [Integrations](#integrations) -- [Issues](#issues) - [Requests](#requests) -- [Bulk Operations](#bulk-operations) -- [Notifications](#notifications) -- [Implementation Status](#implementation-status) - +- [Attempts](#attempts) +- [Utilities](#utilities) + ## Global Options All commands support these global options: -### βœ… Current Global Options -```bash ---profile, -p string Profile name (default "default") ---api-key string Your API key to use for the command (hidden) ---cli-key string CLI key for legacy auth (deprecated, hidden) ---color string Turn on/off color output (on, off, auto) ---config string Config file (default is $HOME/.config/hookdeck/config.toml) ---device-name string Device name for this CLI instance ---log-level string Log level: debug, info, warn, error (default "info") ---insecure Allow invalid TLS certificates ---version, -v Show version information ---help, -h Show help information -``` + +| Flag | Type | Description | +|------|------|-------------| +| `--color` | `string` | turn on/off color output (on, off, auto) | +| `--config` | `string` | config file (default is $HOME/.config/hookdeck/config.toml) | +| `--device-name` | `string` | device name | +| `--insecure` | `bool` | Allow invalid TLS certificates | +| `--log-level` | `string` | log level (debug, info, warn, error) (default "info") | +| `-p, --profile` | `string` | profile name (default "default") | +| `-v, --version` | `bool` | Get the version of the Hookdeck CLI | + + +## Authentication -### πŸ”„ Partially Implemented Options -```bash ---output json Output in JSON format (available on: connection create/list/get/upsert) - Default: human-readable format -``` + +- [hookdeck login](#hookdeck-login) +- [hookdeck logout](#hookdeck-logout) +- [hookdeck whoami](#hookdeck-whoami) -### οΏ½ Planned Global Options -```bash ---project string Project ID to use (overrides profile) ---output string Additional output formats: table, yaml (currently only json supported) -``` +### hookdeck login -## Authentication +Login to your Hookdeck account to setup the CLI + +**Usage:** -**All Parameters:** ```bash -# Login command parameters ---api-key string API key for direct authentication ---interactive, -i Interactive login with prompts (boolean flag) ---profile string Profile name to use for login +hookdeck login [flags] +``` -# Logout command parameters ---all, -a Logout all profiles (boolean flag) ---profile string Profile name to logout +**Flags:** -# Whoami command parameters -# (No additional parameters - uses global options only) -``` +| Flag | Type | Description | +|------|------|-------------| +| `-i, --interactive` | `bool` | Run interactive configuration mode if you cannot open a browser | +### hookdeck logout -### βœ… Login -```bash -# Interactive login with prompts -hookdeck login -hookdeck login --interactive -hookdeck login -i +Logout of your Hookdeck account to setup the CLI -# Login with API key directly -hookdeck login --api-key your_api_key +**Usage:** -# Use different profile -hookdeck login --profile production +```bash +hookdeck logout [flags] ``` -### βœ… Logout -```bash -# Logout current profile -hookdeck logout +**Flags:** -# Logout specific profile -hookdeck logout --profile production +| Flag | Type | Description | +|------|------|-------------| +| `-a, --all` | `bool` | Clear credentials for all projects you are currently logged into. | +### hookdeck whoami -# Logout all profiles -hookdeck logout --all -hookdeck logout -a -``` +Show the logged-in user + +**Usage:** -### βœ… Check authentication status ```bash hookdeck whoami - -# Example output: -# Using profile default (use -p flag to use a different config profile) -# -# Logged in as john@example.com (John Doe) on project Production in organization Acme Corp ``` - + ## Projects -**All Parameters:** -```bash -# Project list command parameters -[organization_substring] [project_substring] # Positional arguments for filtering -# (No additional flag parameters) - -# Project use command parameters -[project-id] # Positional argument for specific project ID ---profile string # Profile name to use + +- [hookdeck project list](#hookdeck-project-list) +- [hookdeck project use](#hookdeck-project-use) -# Project create command parameters (planned) ---name string # Required: Project name ---description string # Optional: Project description +### hookdeck project list -# Project get command parameters (planned) -[project-id] # Positional argument for specific project ID +List and filter projects by organization and project name substrings -# Project update command parameters (planned) - # Required positional argument for project ID ---name string # Update project name ---description string # Update project description +**Usage:** -# Project delete command parameters (planned) - # Required positional argument for project ID ---force # Force delete without confirmation (boolean flag) +```bash +hookdeck project list [] [] ``` +### hookdeck project use -Projects are top-level containers for your webhook infrastructure. +Set the active project for future commands + +**Usage:** -### βœ… List projects ```bash -# List all projects you have access to -hookdeck project list +hookdeck project use [ []] [flags] +``` -# Filter by organization substring -hookdeck project list acme +**Flags:** -# Filter by organization and project substrings -hookdeck project list acme production +| Flag | Type | Description | +|------|------|-------------| +| `--local` | `bool` | Save project to current directory (.hookdeck/config.toml) | + +## Local Development -# Example output: -# [Acme Corp] Production -# [Acme Corp] Staging (current) -# [Test Org] Development -``` + +### hookdeck listen -### βœ… Use project (set as current) -```bash -# Interactive selection from available projects -hookdeck project use +Forward events for a source to your local server. -# Use specific project by ID -hookdeck project use proj_123 +This command will create a new Hookdeck Source if it doesn't exist. -# Use with different profile -hookdeck project use --profile production -``` +By default the Hookdeck Destination will be named "{source}-cli", and the +Destination CLI path will be "/". To set the CLI path, use the "`--path`" flag. -## Local Development +**Usage:** -**All Parameters:** ```bash -# Listen command parameters -[port or URL] # Required positional argument (e.g., "3000" or "http://localhost:3000") -[source] # Optional positional argument for source name -[connection] # Optional positional argument for connection name ---path string # Specific path to forward to (e.g., "/webhooks") ---no-healthcheck # Disable periodic health checks of the local server ---no-wss # Force unencrypted WebSocket connection (hidden flag) +hookdeck listen [flags] ``` -### βœ… Listen for webhooks -```bash -# Start webhook forwarding to localhost (with interactive prompts) -hookdeck listen - -# Forward to specific port -hookdeck listen 3000 +**Flags:** -# Forward to specific URL -hookdeck listen http://localhost:3000 +| Flag | Type | Description | +|------|------|-------------| +| `--filter-body` | `string` | Filter events by request body using Hookdeck filter syntax (JSON) | +| `--filter-headers` | `string` | Filter events by request headers using Hookdeck filter syntax (JSON) | +| `--filter-path` | `string` | Filter events by request path using Hookdeck filter syntax (JSON) | +| `--filter-query` | `string` | Filter events by query parameters using Hookdeck filter syntax (JSON) | +| `--max-connections` | `int` | Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing) (default "50") | +| `--no-healthcheck` | `bool` | Disable periodic health checks of the local server | +| `--output` | `string` | Output mode: interactive (full UI), compact (simple logs), quiet (errors and warnings only) (default "interactive") | +| `--path` | `string` | Sets the path to which events are forwarded e.g., /webhooks or /api/stripe | + +## Gateway -# Forward with source and connection specified -hookdeck listen 3000 stripe-webhooks payment-connection + +### hookdeck gateway -# Forward to specific path -hookdeck listen --path /webhooks +Commands for managing Event Gateway sources, destinations, connections, +transformations, events, requests, and metrics. -# Disable periodic health checks of the local server -hookdeck listen --no-healthcheck 3000 +The gateway command group provides full access to all Event Gateway resources. -# Force unencrypted WebSocket connection (hidden flag) -hookdeck listen --no-wss +**Usage:** -# Arguments: -# - port or URL: Required (e.g., "3000" or "http://localhost:3000") -# - source: Optional source name to forward from -# - connection: Optional connection name +```bash +hookdeck gateway ``` -The `listen` command forwards webhooks from Hookdeck to your local development server, allowing you to test webhook integrations locally. +**Examples:** -## CI/CD Integration - -**All Parameters:** ```bash -# CI command parameters ---api-key string # API key (defaults to HOOKDECK_API_KEY env var) ---name string # CI name (e.g., $GITHUB_REF for GitHub Actions) +# List connections +hookdeck gateway connection list + +# Create a source +hookdeck gateway source create --name my-source --type WEBHOOK + +# Query event metrics +hookdeck gateway metrics events --start 2026-01-01T00:00:00Z --end 2026-02-01T00:00:00Z ``` + +## Connections -### βœ… CI command -```bash -# Run in CI/CD environments -hookdeck ci + +- [hookdeck gateway connection list](#hookdeck-gateway-connection-list) +- [hookdeck gateway connection create](#hookdeck-gateway-connection-create) +- [hookdeck gateway connection get](#hookdeck-gateway-connection-get) +- [hookdeck gateway connection update](#hookdeck-gateway-connection-update) +- [hookdeck gateway connection delete](#hookdeck-gateway-connection-delete) +- [hookdeck gateway connection upsert](#hookdeck-gateway-connection-upsert) +- [hookdeck gateway connection enable](#hookdeck-gateway-connection-enable) +- [hookdeck gateway connection disable](#hookdeck-gateway-connection-disable) +- [hookdeck gateway connection pause](#hookdeck-gateway-connection-pause) +- [hookdeck gateway connection unpause](#hookdeck-gateway-connection-unpause) + +### hookdeck gateway connection list -# Specify API key explicitly (defaults to HOOKDECK_API_KEY env var) -hookdeck ci --api-key +List all connections or filter by source/destination. -# Specify CI name (e.g., for GitHub Actions) -hookdeck ci --name $GITHUB_REF +**Usage:** + +```bash +hookdeck gateway connection list [flags] ``` -This command provides CI/CD specific functionality for automated deployments and testing. +**Flags:** -## Utilities +| Flag | Type | Description | +|------|------|-------------| +| `--destination-id` | `string` | Filter by destination ID | +| `--disabled` | `bool` | Include disabled connections | +| `--limit` | `int` | Limit number of results (default "100") | +| `--name` | `string` | Filter by connection name | +| `--output` | `string` | Output format (json) | +| `--source-id` | `string` | Filter by source ID | + +**Examples:** -**All Parameters:** ```bash -# Completion command parameters -[shell] # Positional argument for shell type (bash, zsh, fish, powershell) ---shell string # Explicit shell selection flag +# List all connections +hookdeck connection list -# Version command parameters -# (No additional parameters - uses global options only) -``` +# Filter by connection name +hookdeck connection list --name my-connection -### βœ… Shell completion -```bash -# Generate completion (auto-detects bash or zsh from $SHELL) -hookdeck completion +# Filter by source ID +hookdeck connection list --source-id src_abc123 -# Specify shell explicitly -hookdeck completion --shell bash -hookdeck completion --shell zsh +# Filter by destination ID +hookdeck connection list --destination-id dst_def456 -# Note: Only bash and zsh are currently supported -# The CLI auto-detects your shell from the SHELL environment variable +# Include disabled connections +hookdeck connection list --disabled + +# Limit results +hookdeck connection list --limit 10 ``` +### hookdeck gateway connection create -### βœ… Version information -```bash -hookdeck version +Create a connection between a source and destination. + + You can either reference existing resources by ID or create them inline. + +**Usage:** -# Short version -hookdeck --version +```bash +hookdeck gateway connection create [flags] ``` -## Current Limitations +**Flags:** -The Hookdeck CLI provides comprehensive connection management capabilities. The following limitations currently exist: +| Flag | Type | Description | +|------|------|-------------| +| `--description` | `string` | Connection description | +| `--destination-api-key` | `string` | API key for destination authentication | +| `--destination-api-key-header` | `string` | Key/header name for API key authentication | +| `--destination-api-key-to` | `string` | Where to send API key: 'header' or 'query' (default "header") | +| `--destination-auth-method` | `string` | Authentication method for HTTP destinations (hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws, gcp) | +| `--destination-aws-access-key-id` | `string` | AWS access key ID | +| `--destination-aws-region` | `string` | AWS region | +| `--destination-aws-secret-access-key` | `string` | AWS secret access key | +| `--destination-aws-service` | `string` | AWS service name | +| `--destination-basic-auth-pass` | `string` | Password for destination Basic authentication | +| `--destination-basic-auth-user` | `string` | Username for destination Basic authentication | +| `--destination-bearer-token` | `string` | Bearer token for destination authentication | +| `--destination-cli-path` | `string` | CLI path for CLI destinations (default: /) (default "/") | +| `--destination-custom-signature-key` | `string` | Key/header name for custom signature | +| `--destination-custom-signature-secret` | `string` | Signing secret for custom signature | +| `--destination-description` | `string` | Destination description | +| `--destination-gcp-scope` | `string` | GCP scope for service account authentication | +| `--destination-gcp-service-account-key` | `string` | GCP service account key JSON for destination authentication | +| `--destination-http-method` | `string` | HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE) | +| `--destination-id` | `string` | Use existing destination by ID | +| `--destination-name` | `string` | Destination name for inline creation | +| `--destination-oauth2-auth-server` | `string` | OAuth2 authorization server URL | +| `--destination-oauth2-auth-type` | `string` | OAuth2 Client Credentials authentication type: 'basic', 'bearer', or 'x-www-form-urlencoded' (default "basic") | +| `--destination-oauth2-client-id` | `string` | OAuth2 client ID | +| `--destination-oauth2-client-secret` | `string` | OAuth2 client secret | +| `--destination-oauth2-refresh-token` | `string` | OAuth2 refresh token (required for Authorization Code flow) | +| `--destination-oauth2-scopes` | `string` | OAuth2 scopes (comma-separated) | +| `--destination-path-forwarding-disabled` | `string` | Disable path forwarding for HTTP destinations (true/false) | +| `--destination-rate-limit` | `int` | Rate limit for destination (requests per period) (default "0") | +| `--destination-rate-limit-period` | `string` | Rate limit period (second, minute, hour, concurrent) | +| `--destination-type` | `string` | Destination type (CLI, HTTP, MOCK) | +| `--destination-url` | `string` | URL for HTTP destinations | +| `--name` | `string` | Connection name (required) | +| `--output` | `string` | Output format (json) | +| `--rule-deduplicate-exclude-fields` | `string` | Comma-separated list of fields to exclude for deduplication | +| `--rule-deduplicate-include-fields` | `string` | Comma-separated list of fields to include for deduplication | +| `--rule-deduplicate-window` | `int` | Time window in seconds for deduplication (default "0") | +| `--rule-delay` | `int` | Delay in milliseconds (default "0") | +| `--rule-filter-body` | `string` | JQ expression to filter on request body | +| `--rule-filter-headers` | `string` | JQ expression to filter on request headers | +| `--rule-filter-path` | `string` | JQ expression to filter on request path | +| `--rule-filter-query` | `string` | JQ expression to filter on request query parameters | +| `--rule-retry-count` | `int` | Number of retry attempts (default "0") | +| `--rule-retry-interval` | `int` | Interval between retries in milliseconds (default "0") | +| `--rule-retry-response-status-codes` | `string` | Comma-separated HTTP status codes to retry on | +| `--rule-retry-strategy` | `string` | Retry strategy (linear, exponential) | +| `--rule-transform-code` | `string` | Transformation code (if creating inline) | +| `--rule-transform-env` | `string` | JSON string representing environment variables for transformation | +| `--rule-transform-name` | `string` | Name or ID of the transformation to apply | +| `--rules` | `string` | JSON string representing the entire rules array | +| `--rules-file` | `string` | Path to a JSON file containing the rules array | +| `--source-allowed-http-methods` | `string` | Comma-separated list of allowed HTTP methods (GET, POST, PUT, PATCH, DELETE) | +| `--source-api-key` | `string` | API key for source authentication | +| `--source-basic-auth-pass` | `string` | Password for Basic authentication | +| `--source-basic-auth-user` | `string` | Username for Basic authentication | +| `--source-config` | `string` | JSON string for source authentication config | +| `--source-config-file` | `string` | Path to a JSON file for source authentication config | +| `--source-custom-response-body` | `string` | Custom response body (max 1000 chars) | +| `--source-custom-response-content-type` | `string` | Custom response content type (json, text, xml) | +| `--source-description` | `string` | Source description | +| `--source-hmac-algo` | `string` | HMAC algorithm (SHA256, etc.) | +| `--source-hmac-secret` | `string` | HMAC secret for signature verification | +| `--source-id` | `string` | Use existing source by ID | +| `--source-name` | `string` | Source name for inline creation | +| `--source-type` | `string` | Source type (WEBHOOK, STRIPE, etc.) | +| `--source-webhook-secret` | `string` | Webhook secret for source verification (e.g., Stripe) | + +**Examples:** + +```bash +# Create with inline source and destination +hookdeck connection create \ +--name "test-webhooks-to-local" \ +--source-type WEBHOOK --source-name "test-webhooks" \ +--destination-type CLI --destination-name "local-dev" -- ❌ **No dedicated event querying commands** - No standalone commands for event/request queries (but events can be inspected and retried in `listen` interactive mode) -- ❌ **Limited bulk operations** - Cannot perform batch operations on resources (e.g., bulk retry, bulk delete) -- ❌ **No project creation** - Cannot create, update, or delete projects via CLI (only list and use existing projects) -- ❌ **No source/destination management** - Sources and destinations must be created inline via connection create or via Hookdeck dashboard -- ❌ **No transformation management** - Transformations must be created via Hookdeck dashboard or API -- ❌ **No attempt management** - Cannot query or manage individual delivery attempts via dedicated commands -- ❌ **No issue management** - Cannot view or manage issues from CLI +# Create with existing resources +hookdeck connection create \ +--name "github-to-api" \ +--source-id src_abc123 \ +--destination-id dst_def456 ---- +# Create with source configuration options +hookdeck connection create \ +--name "api-webhooks" \ +--source-type WEBHOOK --source-name "api-source" \ +--source-allowed-http-methods "POST,PUT,PATCH" \ +--source-custom-response-content-type "json" \ +--source-custom-response-body '{"status":"received"}' \ +--destination-type CLI --destination-name "local-dev" +``` +### hookdeck gateway connection get -# 🚧 Planned Functionality +Get detailed information about a specific connection. -*The following sections document planned functionality that is not yet implemented. This serves as a specification for future development.* +You can specify either a connection ID or name. -## Implementation Status +**Usage:** -| Command Category | Status | Available Commands | -|------------------|--------|-------------------| -| Authentication | βœ… **Current** | `login`, `logout`, `whoami` | -| Project Management | πŸ”„ **Partial** | `project list`, `project use` | -| Local Development | βœ… **Current** | `listen` | -| CI/CD | βœ… **Current** | `ci` | -| Connection Management | βœ… **Current** | `connection create`, `connection list`, `connection get`, `connection upsert`, `connection delete`, `connection enable`, `connection disable`, `connection pause`, `connection unpause` | -| Shell Completion | βœ… **Current** | `completion` (bash, zsh) | -| Source Management | 🚧 **Planned** | *(Not implemented)* | -| Destination Management | 🚧 **Planned** | *(Not implemented)* | -| Transformation Management | 🚧 **Planned** | *(Not implemented)* | -| Issue Trigger Management | 🚧 **Planned** | *(Not implemented)* | -| Event Querying | 🚧 **Planned** | *(Not implemented)* | -| Attempt Management | 🚧 **Planned** | *(Not implemented)* | -| Bookmark Management | 🚧 **Planned** | *(Not implemented)* | -| Integration Management | 🚧 **Planned** | *(Not implemented)* | -| Issue Management | 🚧 **Planned** | *(Not implemented)* | -| Request Management | 🚧 **Planned** | *(Not implemented)* | -| Bulk Operations | 🚧 **Planned** | *(Not implemented)* | +```bash +hookdeck gateway connection get [flags] +``` -## Advanced Project Management +**Flags:** -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +| Flag | Type | Description | +|------|------|-------------| +| `--include-destination-auth` | `bool` | Include destination authentication credentials in the response | +| `--include-source-auth` | `bool` | Include source authentication credentials in the response | +| `--output` | `string` | Output format (json) | -*Note: These project management commands are planned for implementation as documented in `.plans/resource-management-implementation.md` and are being developed in the `feat/project-create` branch.* +**Examples:** -### Create a project ```bash -# Create with interactive prompts -hookdeck project create +# Get connection by ID +hookdeck connection get conn_abc123 -# Create with flags -hookdeck project create --name "My Project" --description "Production webhooks" +# Get connection by name +hookdeck connection get my-connection ``` +### hookdeck gateway connection update -### Get project details -```bash -# Get current project -hookdeck project get +Update an existing connection by its ID. -# Get specific project -hookdeck project get proj_123 +Unlike upsert (which uses name as identifier), update takes a connection ID +and allows changing any field including the connection name. -# Get with full details -hookdeck project get proj_123 --log-level debug -``` +**Usage:** -### Update project ```bash -# Update interactively -hookdeck project update +hookdeck gateway connection update [flags] +``` + +**Flags:** -# Update specific project -hookdeck project update proj_123 --name "Updated Name" +| Flag | Type | Description | +|------|------|-------------| +| `--description` | `string` | Connection description | +| `--destination-id` | `string` | Update destination by ID | +| `--name` | `string` | New connection name | +| `--output` | `string` | Output format (json) | +| `--rule-deduplicate-exclude-fields` | `string` | Comma-separated list of fields to exclude for deduplication | +| `--rule-deduplicate-include-fields` | `string` | Comma-separated list of fields to include for deduplication | +| `--rule-deduplicate-window` | `int` | Time window in seconds for deduplication (default "0") | +| `--rule-delay` | `int` | Delay in milliseconds (default "0") | +| `--rule-filter-body` | `string` | JQ expression to filter on request body | +| `--rule-filter-headers` | `string` | JQ expression to filter on request headers | +| `--rule-filter-path` | `string` | JQ expression to filter on request path | +| `--rule-filter-query` | `string` | JQ expression to filter on request query parameters | +| `--rule-retry-count` | `int` | Number of retry attempts (default "0") | +| `--rule-retry-interval` | `int` | Interval between retries in milliseconds (default "0") | +| `--rule-retry-response-status-codes` | `string` | Comma-separated HTTP status codes to retry on | +| `--rule-retry-strategy` | `string` | Retry strategy (linear, exponential) | +| `--rule-transform-code` | `string` | Transformation code (if creating inline) | +| `--rule-transform-env` | `string` | JSON string representing environment variables for transformation | +| `--rule-transform-name` | `string` | Name or ID of the transformation to apply | +| `--rules` | `string` | JSON string representing the entire rules array | +| `--rules-file` | `string` | Path to a JSON file containing the rules array | +| `--source-id` | `string` | Update source by ID | + +**Examples:** + +```bash +# Rename a connection +hookdeck gateway connection update web_abc123 --name "new-name" # Update description -hookdeck project update proj_123 --description "New description" -``` +hookdeck gateway connection update web_abc123 --description "Updated description" -### Delete project -```bash -# Delete with confirmation -hookdeck project delete proj_123 +# Change the source on a connection +hookdeck gateway connection update web_abc123 --source-id src_def456 -# Force delete without confirmation -hookdeck project delete proj_123 --force -``` +# Update rules +hookdeck gateway connection update web_abc123 \ +--rule-retry-strategy linear --rule-retry-count 5 -## Sources +# Update with JSON output +hookdeck gateway connection update web_abc123 --name "new-name" --output json +``` +### hookdeck gateway connection delete -**All Parameters:** -```bash -# Source list command parameters ---name string # Filter by name pattern (supports wildcards) ---type string # Filter by source type (96+ types supported) ---disabled # Include disabled sources (boolean flag) ---order-by string # Sort by: name, created_at, updated_at ---dir string # Sort direction: asc, desc ---limit integer # Limit number of results (0-255) ---next string # Next page token for pagination ---prev string # Previous page token for pagination - -# Source count command parameters ---name string # Filter by name pattern ---disabled # Include disabled sources (boolean flag) - -# Source get command parameters - # Required positional argument for source ID ---include string # Include additional data (e.g., "config.auth") - -# Source create command parameters ---name string # Required: Source name ---type string # Required: Source type (see type-specific parameters below) ---description string # Optional: Source description - -# Type-specific parameters for source create/update/upsert: -# When --type=STRIPE, GITHUB, SHOPIFY, SLACK, TWILIO, etc.: ---webhook-secret string # Webhook secret for signature verification - -# When --type=PAYPAL: ---webhook-id string # PayPal webhook ID (not webhook_secret) - -# When --type=GITLAB, OKTA, MERAKI, etc.: ---api-key string # API key for authentication - -# When --type=BRIDGE, FIREBLOCKS, DISCORD, TELNYX, etc.: ---public-key string # Public key for signature verification - -# When --type=POSTMARK, PIPEDRIVE, etc.: ---username string # Username for basic authentication ---password string # Password for basic authentication - -# When --type=RING_CENTRAL, etc.: ---token string # Authentication token - -# When --type=EBAY (complex multi-field authentication): ---environment string # PRODUCTION or SANDBOX ---dev-id string # Developer ID ---client-id string # Client ID ---client-secret string # Client secret ---verification-token string # Verification token - -# When --type=TIKTOK_SHOP (multi-key authentication): ---webhook-secret string # Webhook secret ---app-key string # Application key - -# When --type=FISERV: ---webhook-secret string # Webhook secret ---store-name string # Optional: Store name - -# When --type=VERCEL_LOG_DRAINS: ---webhook-secret string # Webhook secret ---log-drains-secret string # Optional: Log drains secret - -# When --type=HTTP (custom HTTP source): ---auth-type string # Authentication type (HMAC, API_KEY, BASIC, etc.) ---algorithm string # HMAC algorithm (sha256, sha1, etc.) ---encoding string # HMAC encoding (hex, base64, etc.) ---header-key string # Header name for signature/API key ---webhook-secret string # Secret for HMAC verification ---auth-key string # API key for API_KEY auth type ---auth-username string # Username for BASIC auth type ---auth-password string # Password for BASIC auth type ---allowed-methods string # Comma-separated HTTP methods (GET,POST,PUT,DELETE) ---custom-response-status integer # Custom response status code ---custom-response-body string # Custom response body ---custom-response-headers string # Custom response headers (key=value,key2=value2) - -# Source update command parameters - # Required positional argument for source ID ---name string # Update source name ---description string # Update source description -# Plus any type-specific parameters listed above - -# Source upsert command parameters (create or update by name) ---name string # Required: Source name (used for matching existing) ---type string # Required: Source type -# Plus any type-specific parameters listed above - -# Source delete command parameters - # Required positional argument for source ID ---force # Force delete without confirmation (boolean flag) - -# Source enable/disable command parameters - # Required positional argument for source ID -``` - -**Type Validation Rules:** -- **webhook_secret_key types**: STRIPE, GITHUB, SHOPIFY, SLACK, TWILIO, SQUARE, WOOCOMMERCE, TEBEX, MAILCHIMP, PADDLE, TREEZOR, PRAXIS, CUSTOMERIO, EXACT_ONLINE, FACEBOOK, WHATSAPP, REPLICATE, TIKTOK, FISERV, VERCEL_LOG_DRAINS, etc. -- **webhook_id types**: PAYPAL (uses webhook_id instead of webhook_secret) -- **api_key types**: GITLAB, OKTA, MERAKI, CLOUDSIGNAL, etc. -- **public_key types**: BRIDGE, FIREBLOCKS, DISCORD, TELNYX, etc. -- **basic_auth types**: POSTMARK, PIPEDRIVE, etc. -- **token types**: RING_CENTRAL, etc. -- **complex_auth types**: EBAY (5 fields), TIKTOK_SHOP (2 fields) -- **minimal_config types**: AWS_SNS (no additional auth required) - -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented - -Sources represent the webhook providers that send webhooks to Hookdeck. The API supports 96+ provider types with specific authentication requirements. +Delete a connection. -### List sources -```bash -# List all sources -hookdeck source list +**Usage:** -# Filter by name pattern -hookdeck source list --name "stripe*" +```bash +hookdeck gateway connection delete [flags] +``` -# Filter by type (supports 80+ types) -hookdeck source list --type STRIPE - -# Include disabled sources -hookdeck source list --disabled +**Flags:** -# Limit results -hookdeck source list --limit 50 +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Force delete without confirmation | -# Combined filtering -hookdeck source list --name "*prod*" --type GITHUB --limit 25 -``` +**Examples:** -### Count sources ```bash -# Count all sources -hookdeck source count +# Delete a connection (with confirmation) +hookdeck connection delete conn_abc123 -# Count with filters -hookdeck source count --name "*stripe*" --disabled +# Force delete without confirmation +hookdeck connection delete conn_abc123 --force ``` +### hookdeck gateway connection upsert -### Get source details -```bash -# Get source by ID -hookdeck source get +Create a new connection or update an existing one by name (idempotent). -# Include authentication configuration -hookdeck source get --include config.auth -``` + This command is idempotent - it can be safely run multiple times with the same arguments. + + When the connection doesn't exist: + - Creates a new connection with the provided properties + - Requires source and destination to be specified + + When the connection exists: + - Updates the connection with the provided properties + - Only updates properties that are explicitly provided + - Preserves existing properties that aren't specified + + Use `--dry-run` to preview changes without applying them. -### Create a source +**Usage:** -#### Interactive creation ```bash -# Create with interactive prompts -hookdeck source create +hookdeck gateway connection upsert [flags] ``` -#### Platform-specific sources (80+ supported types) +**Flags:** -##### Payment Platforms -```bash -# Stripe - Payment webhooks -hookdeck source create --name "stripe-prod" --type STRIPE --webhook-secret "whsec_1a2b3c..." +| Flag | Type | Description | +|------|------|-------------| +| `--description` | `string` | Connection description | +| `--destination-api-key` | `string` | API key for destination authentication | +| `--destination-api-key-header` | `string` | Key/header name for API key authentication | +| `--destination-api-key-to` | `string` | Where to send API key: 'header' or 'query' (default "header") | +| `--destination-auth-method` | `string` | Authentication method for HTTP destinations (hookdeck, bearer, basic, api_key, custom_signature, oauth2_client_credentials, oauth2_authorization_code, aws, gcp) | +| `--destination-aws-access-key-id` | `string` | AWS access key ID | +| `--destination-aws-region` | `string` | AWS region | +| `--destination-aws-secret-access-key` | `string` | AWS secret access key | +| `--destination-aws-service` | `string` | AWS service name | +| `--destination-basic-auth-pass` | `string` | Password for destination Basic authentication | +| `--destination-basic-auth-user` | `string` | Username for destination Basic authentication | +| `--destination-bearer-token` | `string` | Bearer token for destination authentication | +| `--destination-cli-path` | `string` | CLI path for CLI destinations (default: /) (default "/") | +| `--destination-custom-signature-key` | `string` | Key/header name for custom signature | +| `--destination-custom-signature-secret` | `string` | Signing secret for custom signature | +| `--destination-description` | `string` | Destination description | +| `--destination-gcp-scope` | `string` | GCP scope for service account authentication | +| `--destination-gcp-service-account-key` | `string` | GCP service account key JSON for destination authentication | +| `--destination-http-method` | `string` | HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE) | +| `--destination-id` | `string` | Use existing destination by ID | +| `--destination-name` | `string` | Destination name for inline creation | +| `--destination-oauth2-auth-server` | `string` | OAuth2 authorization server URL | +| `--destination-oauth2-auth-type` | `string` | OAuth2 Client Credentials authentication type: 'basic', 'bearer', or 'x-www-form-urlencoded' (default "basic") | +| `--destination-oauth2-client-id` | `string` | OAuth2 client ID | +| `--destination-oauth2-client-secret` | `string` | OAuth2 client secret | +| `--destination-oauth2-refresh-token` | `string` | OAuth2 refresh token (required for Authorization Code flow) | +| `--destination-oauth2-scopes` | `string` | OAuth2 scopes (comma-separated) | +| `--destination-path-forwarding-disabled` | `string` | Disable path forwarding for HTTP destinations (true/false) | +| `--destination-rate-limit` | `int` | Rate limit for destination (requests per period) (default "0") | +| `--destination-rate-limit-period` | `string` | Rate limit period (second, minute, hour, concurrent) | +| `--destination-type` | `string` | Destination type (CLI, HTTP, MOCK) | +| `--destination-url` | `string` | URL for HTTP destinations | +| `--dry-run` | `bool` | Preview changes without applying them | +| `--output` | `string` | Output format (json) | +| `--rule-deduplicate-exclude-fields` | `string` | Comma-separated list of fields to exclude for deduplication | +| `--rule-deduplicate-include-fields` | `string` | Comma-separated list of fields to include for deduplication | +| `--rule-deduplicate-window` | `int` | Time window in seconds for deduplication (default "0") | +| `--rule-delay` | `int` | Delay in milliseconds (default "0") | +| `--rule-filter-body` | `string` | JQ expression to filter on request body | +| `--rule-filter-headers` | `string` | JQ expression to filter on request headers | +| `--rule-filter-path` | `string` | JQ expression to filter on request path | +| `--rule-filter-query` | `string` | JQ expression to filter on request query parameters | +| `--rule-retry-count` | `int` | Number of retry attempts (default "0") | +| `--rule-retry-interval` | `int` | Interval between retries in milliseconds (default "0") | +| `--rule-retry-response-status-codes` | `string` | Comma-separated HTTP status codes to retry on | +| `--rule-retry-strategy` | `string` | Retry strategy (linear, exponential) | +| `--rule-transform-code` | `string` | Transformation code (if creating inline) | +| `--rule-transform-env` | `string` | JSON string representing environment variables for transformation | +| `--rule-transform-name` | `string` | Name or ID of the transformation to apply | +| `--rules` | `string` | JSON string representing the entire rules array | +| `--rules-file` | `string` | Path to a JSON file containing the rules array | +| `--source-allowed-http-methods` | `string` | Comma-separated list of allowed HTTP methods (GET, POST, PUT, PATCH, DELETE) | +| `--source-api-key` | `string` | API key for source authentication | +| `--source-basic-auth-pass` | `string` | Password for Basic authentication | +| `--source-basic-auth-user` | `string` | Username for Basic authentication | +| `--source-config` | `string` | JSON string for source authentication config | +| `--source-config-file` | `string` | Path to a JSON file for source authentication config | +| `--source-custom-response-body` | `string` | Custom response body (max 1000 chars) | +| `--source-custom-response-content-type` | `string` | Custom response content type (json, text, xml) | +| `--source-description` | `string` | Source description | +| `--source-hmac-algo` | `string` | HMAC algorithm (SHA256, etc.) | +| `--source-hmac-secret` | `string` | HMAC secret for signature verification | +| `--source-id` | `string` | Use existing source by ID | +| `--source-name` | `string` | Source name for inline creation | +| `--source-type` | `string` | Source type (WEBHOOK, STRIPE, etc.) | +| `--source-webhook-secret` | `string` | Webhook secret for source verification (e.g., Stripe) | + +**Examples:** + +```bash +# Create or update a connection with inline source and destination +hookdeck connection upsert "my-connection" \ +--source-name "stripe-prod" --source-type STRIPE \ +--destination-name "my-api" --destination-type HTTP --destination-url https://api.example.com + +# Update just the rate limit on an existing connection +hookdeck connection upsert my-connection \ +--destination-rate-limit 100 --destination-rate-limit-period minute -# PayPal - Payment events (uses webhook_id not webhook_secret) -hookdeck source create --name "paypal-prod" --type PAYPAL --webhook-id "webhook_id_value" +# Update source configuration options +hookdeck connection upsert my-connection \ +--source-allowed-http-methods "POST,PUT,DELETE" \ +--source-custom-response-content-type "json" \ +--source-custom-response-body '{"status":"received"}' -# Square - POS and payment events -hookdeck source create --name "square-webhooks" --type SQUARE --webhook-secret "webhook_secret" +# Preview changes without applying them +hookdeck connection upsert my-connection \ +--destination-rate-limit 200 --destination-rate-limit-period hour \ +--dry-run ``` +### hookdeck gateway connection enable -##### Repository and CI/CD -```bash -# GitHub - Repository webhooks -hookdeck source create --name "github-repo" --type GITHUB --webhook-secret "github_secret" +Enable a disabled connection. -# GitLab - Repository and CI webhooks -hookdeck source create --name "gitlab-project" --type GITLAB --api-key "gitlab_token" +**Usage:** -# Bitbucket - Repository events -hookdeck source create --name "bitbucket-repo" --type BITBUCKET --webhook-secret "webhook_secret" -``` - -##### E-commerce Platforms ```bash -# Shopify - Store webhooks -hookdeck source create --name "shopify-store" --type SHOPIFY --webhook-secret "shopify_secret" +hookdeck gateway connection enable +``` +### hookdeck gateway connection disable -# WooCommerce - WordPress e-commerce -hookdeck source create --name "woocommerce-store" --type WOOCOMMERCE --webhook-secret "webhook_secret" +Disable an active connection. It will stop receiving new events until re-enabled. -# Magento - Enterprise e-commerce -hookdeck source create --name "magento-store" --type MAGENTO --webhook-secret "webhook_secret" -``` +**Usage:** -##### Communication Platforms ```bash -# Slack - Workspace events -hookdeck source create --name "slack-workspace" --type SLACK --webhook-secret "slack_signing_secret" +hookdeck gateway connection disable +``` +### hookdeck gateway connection pause -# Twilio - SMS and voice webhooks -hookdeck source create --name "twilio-sms" --type TWILIO --webhook-secret "twilio_auth_token" +Pause a connection temporarily. -# Discord - Bot interactions -hookdeck source create --name "discord-bot" --type DISCORD --public-key "discord_public_key" +The connection will queue incoming events until unpaused. -# Teams - Microsoft Teams webhooks -hookdeck source create --name "teams-notifications" --type TEAMS --webhook-secret "teams_secret" -``` +**Usage:** -##### Cloud Services ```bash -# AWS SNS - Cloud notifications -hookdeck source create --name "aws-sns" --type AWS_SNS - -# Azure Event Grid - Azure events -hookdeck source create --name "azure-events" --type AZURE_EVENT_GRID --webhook-secret "webhook_secret" - -# Google Cloud Pub/Sub - GCP events -hookdeck source create --name "gcp-pubsub" --type GOOGLE_CLOUD_PUBSUB --webhook-secret "webhook_secret" +hookdeck gateway connection pause ``` +### hookdeck gateway connection unpause -##### CRM and Marketing -```bash -# Salesforce - CRM events -hookdeck source create --name "salesforce-crm" --type SALESFORCE --webhook-secret "salesforce_secret" +Resume a paused connection. -# HubSpot - Marketing automation -hookdeck source create --name "hubspot-marketing" --type HUBSPOT --webhook-secret "hubspot_secret" +The connection will start processing queued events. -# Mailchimp - Email marketing -hookdeck source create --name "mailchimp-campaigns" --type MAILCHIMP --webhook-secret "mailchimp_secret" -``` +**Usage:** -##### Authentication and Identity ```bash -# Auth0 - Identity events -hookdeck source create --name "auth0-identity" --type AUTH0 --webhook-secret "auth0_secret" +hookdeck gateway connection unpause +``` + +## Sources -# Okta - Identity management -hookdeck source create --name "okta-identity" --type OKTA --api-key "okta_api_key" + +- [hookdeck gateway source list](#hookdeck-gateway-source-list) +- [hookdeck gateway source create](#hookdeck-gateway-source-create) +- [hookdeck gateway source get](#hookdeck-gateway-source-get) +- [hookdeck gateway source update](#hookdeck-gateway-source-update) +- [hookdeck gateway source delete](#hookdeck-gateway-source-delete) +- [hookdeck gateway source upsert](#hookdeck-gateway-source-upsert) +- [hookdeck gateway source enable](#hookdeck-gateway-source-enable) +- [hookdeck gateway source disable](#hookdeck-gateway-source-disable) +- [hookdeck gateway source count](#hookdeck-gateway-source-count) -# Firebase Auth - Authentication events -hookdeck source create --name "firebase-auth" --type FIREBASE_AUTH --webhook-secret "firebase_secret" -``` +### hookdeck gateway source list -##### Complex Authentication Examples -```bash -# eBay - Multi-field authentication -hookdeck source create --name "ebay-marketplace" --type EBAY \ - --environment PRODUCTION \ - --dev-id "dev_id" \ - --client-id "client_id" \ - --client-secret "client_secret" \ - --verification-token "verification_token" +List all sources or filter by name or type. -# TikTok Shop - Multi-key authentication -hookdeck source create --name "tiktok-shop" --type TIKTOK_SHOP \ - --webhook-secret "webhook_secret" \ - --app-key "app_key" +**Usage:** -# Custom HTTP with HMAC authentication -hookdeck source create --name "custom-api" --type HTTP \ - --auth-type HMAC \ - --algorithm sha256 \ - --encoding hex \ - --header-key "X-Signature" \ - --webhook-secret "hmac_secret" +```bash +hookdeck gateway source list [flags] ``` -### Update a source -```bash -# Update name and description -hookdeck source update --name "new-name" --description "Updated description" +**Flags:** -# Update webhook secret -hookdeck source update --webhook-secret "new_secret" +| Flag | Type | Description | +|------|------|-------------| +| `--disabled` | `bool` | Include disabled sources | +| `--limit` | `int` | Limit number of results (default "100") | +| `--name` | `string` | Filter by source name | +| `--output` | `string` | Output format (json) | +| `--type` | `string` | Filter by source type (e.g. WEBHOOK, STRIPE) | -# Update type-specific configuration -hookdeck source update --api-key "new_api_key" -``` +**Examples:** -### Upsert a source (create or update by name) ```bash -# Create or update source by name -hookdeck source upsert --name "stripe-prod" --type STRIPE --webhook-secret "new_secret" +hookdeck gateway source list +hookdeck gateway source list --name my-source +hookdeck gateway source list --type WEBHOOK +hookdeck gateway source list --disabled +hookdeck gateway source list --limit 10 ``` +### hookdeck gateway source create -### Delete a source -```bash -# Delete source (with confirmation) -hookdeck source delete +Create a new source. -# Force delete without confirmation -hookdeck source delete --force -``` +Requires `--name` and `--type`. Use `--config` or `--config-file` for authentication (e.g. webhook_secret, api_key). -### Enable/Disable sources -```bash -# Enable source -hookdeck source enable +**Usage:** -# Disable source -hookdeck source disable +```bash +hookdeck gateway source create [flags] ``` -## Destinations +**Flags:** -**All Parameters:** -```bash -# Destination list command parameters ---name string # Filter by name pattern (supports wildcards) ---type string # Filter by destination type (HTTP, CLI, MOCK_API) ---disabled # Include disabled destinations (boolean flag) ---limit integer # Limit number of results (default varies) +| Flag | Type | Description | +|------|------|-------------| +| `--allowed-http-methods` | `string` | Comma-separated allowed HTTP methods (GET, POST, PUT, PATCH, DELETE) | +| `--basic-auth-pass` | `string` | Password for Basic authentication | +| `--basic-auth-user` | `string` | Username for Basic authentication | +| `--config-file` | `string` | Path to JSON file for source config (overrides individual flags if set) | +| `--custom-response-body` | `string` | Custom response body (max 1000 chars) | +| `--custom-response-content-type` | `string` | Custom response content type (json, text, xml) | +| `--description` | `string` | Source description | +| `--hmac-algo` | `string` | HMAC algorithm (SHA256, etc.) | +| `--hmac-secret` | `string` | HMAC secret for signature verification | +| `--name` | `string` | Source name (required) | +| `--output` | `string` | Output format (json) | +| `--type` | `string` | Source type (e.g. WEBHOOK, STRIPE) (required) | +| `--webhook-secret` | `string` | Webhook secret for source verification (e.g., Stripe) | -# Destination count command parameters ---name string # Filter by name pattern ---disabled # Include disabled destinations (boolean flag) +**Examples:** -# Destination get command parameters - # Required positional argument for destination ID ---include string # Include additional data (e.g., "config.auth") +```bash +hookdeck gateway source create --name my-webhook --type WEBHOOK +hookdeck gateway source create --name stripe-prod --type STRIPE --config '{"webhook_secret":"whsec_xxx"}' +``` +### hookdeck gateway source get -# Destination create command parameters ---name string # Required: Destination name ---type string # Optional: Destination type (HTTP, CLI, MOCK_API) - defaults to HTTP ---description string # Optional: Destination description +Get detailed information about a specific source. -# Type-specific parameters for destination create/update/upsert: -# When --type=HTTP (default): ---url string # Required: Destination URL ---auth-type string # Authentication type (BEARER_TOKEN, BASIC_AUTH, API_KEY, OAUTH2_CLIENT_CREDENTIALS) ---auth-token string # Bearer token for BEARER_TOKEN auth ---auth-username string # Username for BASIC_AUTH ---auth-password string # Password for BASIC_AUTH ---auth-key string # API key for API_KEY auth ---auth-header string # Header name for API_KEY auth (e.g., "X-API-Key") ---auth-server string # OAuth2 token server URL for OAUTH2_CLIENT_CREDENTIALS ---client-id string # OAuth2 client ID ---client-secret string # OAuth2 client secret ---headers string # Custom headers (key=value,key2=value2) +You can specify either a source ID or name. -# When --type=CLI: ---path string # Optional: Path for CLI destination +**Usage:** -# When --type=MOCK_API: -# (No additional type-specific parameters required) +```bash +hookdeck gateway source get [flags] +``` -# Destination update command parameters - # Required positional argument for destination ID ---name string # Update destination name ---description string # Update destination description ---url string # Update destination URL (for HTTP type) -# Plus any type-specific auth parameters listed above +**Flags:** -# Destination upsert command parameters (create or update by name) ---name string # Required: Destination name (used for matching existing) ---type string # Optional: Destination type -# Plus any type-specific parameters listed above +| Flag | Type | Description | +|------|------|-------------| +| `--include-auth` | `bool` | Include source authentication credentials in the response | +| `--output` | `string` | Output format (json) | -# Destination delete command parameters - # Required positional argument for destination ID ---force # Force delete without confirmation (boolean flag) +**Examples:** -# Destination enable/disable command parameters - # Required positional argument for destination ID +```bash +hookdeck gateway source get src_abc123 +hookdeck gateway source get my-source --include-auth ``` +### hookdeck gateway source update -**Type Validation Rules:** -- **HTTP destinations**: Require `--url`, support all authentication types -- **CLI destinations**: No URL required, optional `--path` parameter -- **MOCK_API destinations**: No additional parameters required, used for testing - -**Authentication Type Combinations:** -- **BEARER_TOKEN**: Requires `--auth-token` -- **BASIC_AUTH**: Requires `--auth-username` and `--auth-password` -- **API_KEY**: Requires `--auth-key` and `--auth-header` -- **OAUTH2_CLIENT_CREDENTIALS**: Requires `--auth-server`, `--client-id`, and `--client-secret` +Update an existing source by its ID. -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +**Usage:** -Destinations are the endpoints where webhooks are delivered. - -### List destinations ```bash -# List all destinations -hookdeck destination list - -# Filter by name pattern -hookdeck destination list --name "api*" +hookdeck gateway source update [flags] +``` -# Filter by type -hookdeck destination list --type HTTP +**Flags:** -# Include disabled destinations -hookdeck destination list --disabled +| Flag | Type | Description | +|------|------|-------------| +| `--allowed-http-methods` | `string` | Comma-separated allowed HTTP methods (GET, POST, PUT, PATCH, DELETE) | +| `--basic-auth-pass` | `string` | Password for Basic authentication | +| `--basic-auth-user` | `string` | Username for Basic authentication | +| `--config-file` | `string` | Path to JSON file for source config (overrides individual flags if set) | +| `--custom-response-body` | `string` | Custom response body (max 1000 chars) | +| `--custom-response-content-type` | `string` | Custom response content type (json, text, xml) | +| `--description` | `string` | New source description | +| `--hmac-algo` | `string` | HMAC algorithm (SHA256, etc.) | +| `--hmac-secret` | `string` | HMAC secret for signature verification | +| `--name` | `string` | New source name | +| `--output` | `string` | Output format (json) | +| `--type` | `string` | Source type (e.g. WEBHOOK, STRIPE) | +| `--webhook-secret` | `string` | Webhook secret for source verification (e.g., Stripe) | -# Limit results -hookdeck destination list --limit 50 -``` +**Examples:** -### Count destinations ```bash -# Count all destinations -hookdeck destination count - -# Count with filters -hookdeck destination count --name "*prod*" --disabled +hookdeck gateway source update src_abc123 --name new-name +hookdeck gateway source update src_abc123 --description "Updated" +hookdeck gateway source update src_abc123 --config '{"webhook_secret":"whsec_new"}' ``` +### hookdeck gateway source delete -### Get destination details -```bash -# Get destination by ID -hookdeck destination get +Delete a source. -# Include authentication configuration -hookdeck destination get --include config.auth -``` +**Usage:** -### Create a destination ```bash -# Create with interactive prompts -hookdeck destination create - -# HTTP destination with URL -hookdeck destination create --name "my-api" --type HTTP --url "https://api.example.com/webhooks" - -# CLI destination for local development -hookdeck destination create --name "local-dev" --type CLI - -# Mock API destination for testing -hookdeck destination create --name "test-mock" --type MOCK_API - -# HTTP with bearer token authentication -hookdeck destination create --name "secure-api" --type HTTP \ - --url "https://api.example.com/webhooks" \ - --auth-type BEARER_TOKEN \ - --auth-token "your_token" +hookdeck gateway source delete [flags] +``` -# HTTP with basic authentication -hookdeck destination create --name "basic-auth-api" --type HTTP \ - --url "https://api.example.com/webhooks" \ - --auth-type BASIC_AUTH \ - --auth-username "api_user" \ - --auth-password "secure_password" +**Flags:** -# HTTP with API key authentication -hookdeck destination create --name "api-key-endpoint" --type HTTP \ - --url "https://api.example.com/webhooks" \ - --auth-type API_KEY \ - --auth-key "your_api_key" \ - --auth-header "X-API-Key" +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Force delete without confirmation | -# HTTP with custom headers -hookdeck destination create --name "custom-headers-api" --type HTTP \ - --url "https://api.example.com/webhooks" \ - --headers "Content-Type=application/json,X-Custom-Header=value" +**Examples:** -# HTTP with OAuth2 client credentials -hookdeck destination create --name "oauth2-api" --type HTTP \ - --url "https://api.example.com/webhooks" \ - --auth-type OAUTH2_CLIENT_CREDENTIALS \ - --auth-server "https://auth.example.com/token" \ - --client-id "your_client_id" \ - --client-secret "your_client_secret" +```bash +hookdeck gateway source delete src_abc123 +hookdeck gateway source delete src_abc123 --force ``` +### hookdeck gateway source upsert -### Update a destination -```bash -# Update name and URL -hookdeck destination update --name "new-name" --url "https://new-api.example.com" +Create a new source or update an existing one by name (idempotent). -# Update authentication -hookdeck destination update --auth-token "new_token" -``` +**Usage:** -### Upsert a destination (create or update by name) ```bash -# Create or update destination by name -hookdeck destination upsert --name "my-api" --type HTTP --url "https://api.example.com" +hookdeck gateway source upsert [flags] ``` -### Delete a destination -```bash -# Delete destination (with confirmation) -hookdeck destination delete +**Flags:** -# Force delete without confirmation -hookdeck destination delete --force -``` +| Flag | Type | Description | +|------|------|-------------| +| `--allowed-http-methods` | `string` | Comma-separated allowed HTTP methods (GET, POST, PUT, PATCH, DELETE) | +| `--basic-auth-pass` | `string` | Password for Basic authentication | +| `--basic-auth-user` | `string` | Username for Basic authentication | +| `--config-file` | `string` | Path to JSON file for source config (overrides individual flags if set) | +| `--custom-response-body` | `string` | Custom response body (max 1000 chars) | +| `--custom-response-content-type` | `string` | Custom response content type (json, text, xml) | +| `--description` | `string` | Source description | +| `--dry-run` | `bool` | Preview changes without applying | +| `--hmac-algo` | `string` | HMAC algorithm (SHA256, etc.) | +| `--hmac-secret` | `string` | HMAC secret for signature verification | +| `--output` | `string` | Output format (json) | +| `--type` | `string` | Source type (e.g. WEBHOOK, STRIPE) | +| `--webhook-secret` | `string` | Webhook secret for source verification (e.g., Stripe) | -### Enable/Disable destinations -```bash -# Enable destination -hookdeck destination enable +**Examples:** -# Disable destination -hookdeck destination disable +```bash +hookdeck gateway source upsert my-webhook --type WEBHOOK +hookdeck gateway source upsert stripe-prod --type STRIPE --config '{"webhook_secret":"whsec_xxx"}' +hookdeck gateway source upsert my-webhook --description "Updated" --dry-run ``` +### hookdeck gateway source enable -## Connections +Enable a disabled source. -βœ… **Fully Implemented** - Connection management provides comprehensive CRUD operations, lifecycle management, authentication, and rule configuration. +**Usage:** -**Available Commands:** -- `connection create` - Create connections with inline source/destination creation -- `connection list` - List connections with filtering options -- `connection get` - Get detailed connection information -- `connection upsert` - Idempotent create or update operations -- `connection delete` - Delete connections with confirmation -- `connection enable/disable` - Control connection state -- `connection pause/unpause` - Pause/resume event processing +```bash +hookdeck gateway source enable +``` +### hookdeck gateway source disable -**Implementation Status:** -- βœ… Full CRUD operations -- βœ… Inline resource creation with authentication -- βœ… All 5 rule types (retry, filter, transform, delay, deduplicate) -- βœ… Rate limiting configuration -- βœ… Lifecycle management -- βœ… Idempotent upsert with dry-run -- βœ… `--output json` flag for JSON output (create, list, get, upsert commands) -- ❌ Bulk operations (planned) -- ❌ Count command (planned) +Disable an active source. It will stop receiving new events until re-enabled. -### List Connections +**Usage:** ```bash -# List all connections -hookdeck connection list +hookdeck gateway source disable +``` +### hookdeck gateway source count -# Filter by source ID -hookdeck connection list --source-id src_abc123 +Count sources matching optional filters. -# Filter by destination ID -hookdeck connection list --destination-id des_xyz789 +**Usage:** -# Filter by connection name -hookdeck connection list --name "production-connection" +```bash +hookdeck gateway source count [flags] +``` -# Include disabled connections -hookdeck connection list --disabled +**Flags:** -# Combine filters -hookdeck connection list --source-id src_abc123 --disabled +| Flag | Type | Description | +|------|------|-------------| +| `--disabled` | `bool` | Count disabled sources only (when set with other filters) | +| `--name` | `string` | Filter by source name | +| `--type` | `string` | Filter by source type | -# Limit results -hookdeck connection list --limit 50 +**Examples:** -# Output as JSON -hookdeck connection list --output json +```bash +hookdeck gateway source count +hookdeck gateway source count --type WEBHOOK +hookdeck gateway source count --disabled ``` + +## Destinations -**Available Flags:** -- `--name ` - Filter by connection name -- `--source-id ` - Filter by source ID -- `--destination-id ` - Filter by destination ID -- `--disabled` - Include disabled connections -- `--limit ` - Limit number of results (default: 100) -- `--output json` - Output in JSON format - -### Get Connection + +- [hookdeck gateway destination list](#hookdeck-gateway-destination-list) +- [hookdeck gateway destination create](#hookdeck-gateway-destination-create) +- [hookdeck gateway destination get](#hookdeck-gateway-destination-get) +- [hookdeck gateway destination update](#hookdeck-gateway-destination-update) +- [hookdeck gateway destination delete](#hookdeck-gateway-destination-delete) +- [hookdeck gateway destination upsert](#hookdeck-gateway-destination-upsert) +- [hookdeck gateway destination count](#hookdeck-gateway-destination-count) +- [hookdeck gateway destination enable](#hookdeck-gateway-destination-enable) +- [hookdeck gateway destination disable](#hookdeck-gateway-destination-disable) -```bash -# Get by ID -hookdeck connection get conn_abc123 +### hookdeck gateway destination list -# Get by name -hookdeck connection get "my-connection" +List all destinations or filter by name or type. -# Get as JSON -hookdeck connection get conn_abc123 --output json +**Usage:** -# Include destination authentication credentials -hookdeck connection get conn_abc123 --include-destination-auth --output json +```bash +hookdeck gateway destination list [flags] ``` **Flags:** -- `--output json` - Output in JSON format -- `--include-destination-auth` - Include destination authentication credentials in the response (fetches via GET /destinations/{id}?include=config.auth) - -### Create Connection +| Flag | Type | Description | +|------|------|-------------| +| `--disabled` | `bool` | Include disabled destinations | +| `--limit` | `int` | Limit number of results (default "100") | +| `--name` | `string` | Filter by destination name | +| `--output` | `string` | Output format (json) | +| `--type` | `string` | Filter by destination type (HTTP, CLI, MOCK_API) | -Create a new connection with inline source/destination creation or by referencing existing resources. +**Examples:** -#### Basic Examples - -**1. Basic HTTP Connection** ```bash -hookdeck connection create \ - --source-name "webhook-receiver" \ - --source-type HTTP \ - --destination-name "api-endpoint" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/webhooks" +hookdeck gateway destination list +hookdeck gateway destination list --name my-destination +hookdeck gateway destination list --type HTTP +hookdeck gateway destination list --disabled +hookdeck gateway destination list --limit 10 ``` +### hookdeck gateway destination create -**2. Using Existing Resources** -```bash -hookdeck connection create \ - --source "existing-source-name" \ - --destination "existing-dest-name" \ - --name "new-connection" \ - --description "Connects existing resources" -``` +Create a new destination. -#### Authentication Examples +Requires `--name` and `--type`. For HTTP destinations, `--url` is required. Use `--config` or `--config-file` for auth and rate limiting. -**3. Stripe with Webhook Secret** -```bash -hookdeck connection create \ - --source-name "stripe-prod" \ - --source-type STRIPE \ - --source-webhook-secret "whsec_abc123xyz" \ - --destination-name "payment-processor" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/stripe" -``` +**Usage:** -**4. Destination with Hookdeck Signature (Default)** ```bash -# Hookdeck automatically signs outgoing webhooks - no configuration needed -hookdeck connection create \ - --source-name "stripe-webhooks" \ - --source-type STRIPE \ - --source-webhook-secret "whsec_stripe_secret" \ - --destination-name "api-with-verification" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/webhook" \ - --destination-auth-method hookdeck +hookdeck gateway destination create [flags] ``` -*Note: Hookdeck Signature authentication is the default. Hookdeck automatically signs all outgoing webhooks with a signature that can be verified using Hookdeck's verification libraries. No webhook secret needs to be configured.* -**5. Destination with Bearer Token** -```bash -hookdeck connection create \ - --source-name "github-webhooks" \ - --source-type GITHUB \ - --source-webhook-secret "ghp_secret123" \ - --destination-name "ci-system" \ - --destination-type HTTP \ - --destination-url "https://ci.example.com/webhook" \ - --destination-auth-method bearer \ - --destination-bearer-token "bearer_token_xyz" -``` +**Flags:** -**6. Source with Custom Response and Allowed HTTP Methods** -```bash -hookdeck connection create \ - --source-name "api-webhooks" \ - --source-type WEBHOOK \ - --source-allowed-http-methods "POST,PUT,PATCH" \ - --source-custom-response-content-type "json" \ - --source-custom-response-body '{"status":"received","timestamp":"2024-01-01T00:00:00Z"}' \ - --destination-name "webhook-handler" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/webhooks" -``` +| Flag | Type | Description | +|------|------|-------------| +| `--api-key-header` | `string` | Header/key name for API key | +| `--api-key-to` | `string` | Where to send API key (header or query) (default "header") | +| `--auth-method` | `string` | Auth method (hookdeck, bearer, basic, api_key, custom_signature) | +| `--basic-auth-pass` | `string` | Password for Basic auth | +| `--basic-auth-user` | `string` | Username for Basic auth | +| `--bearer-token` | `string` | Bearer token for destination auth | +| `--cli-path` | `string` | Path for CLI destinations (default "/") | +| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | +| `--custom-signature-key` | `string` | Key/header name for custom signature | +| `--custom-signature-secret` | `string` | Signing secret for custom signature | +| `--description` | `string` | Destination description | +| `--http-method` | `string` | HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE) | +| `--name` | `string` | Destination name (required) | +| `--output` | `string` | Output format (json) | +| `--rate-limit` | `int` | Rate limit (requests per period) (default "0") | +| `--rate-limit-period` | `string` | Rate limit period (second, minute, hour, concurrent) | +| `--type` | `string` | Destination type (HTTP, CLI, MOCK_API) (required) | +| `--url` | `string` | URL for HTTP destinations (required for type HTTP) | -#### Rule Configuration Examples +**Examples:** -**7. Retry Rules** ```bash -hookdeck connection create \ - --source-name "payment-webhooks" \ - --source-type STRIPE \ - --destination-name "payment-api" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/payments" \ - --rule-retry-strategy exponential \ - --rule-retry-count 5 \ - --rule-retry-interval 60000 +hookdeck gateway destination create --name my-api --type HTTP --url https://api.example.com/webhooks +hookdeck gateway destination create --name local-cli --type CLI --cli-path /webhooks +hookdeck gateway destination create --name my-api --type HTTP --url https://api.example.com --bearer-token token123 ``` +### hookdeck gateway destination get -**8. Filter Rules** -```bash -hookdeck connection create \ - --source-name "events" \ - --source-type HTTP \ - --destination-name "processor" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/process" \ - --rule-filter-body '{"event_type":"payment.succeeded"}' -``` +Get detailed information about a specific destination. -**9. All Rule Types Combined** -```bash -hookdeck connection create \ - --source-name "shopify-webhooks" \ - --source-type SHOPIFY \ - --destination-name "order-processor" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/orders" \ - --rule-filter-body '{"type":"order"}' \ - --rule-retry-strategy exponential \ - --rule-retry-count 3 \ - --rule-retry-interval 30000 \ - --rule-transform-name "order-transformer" \ - --rule-delay 5000 -``` +You can specify either a destination ID or name. -**10. Rate Limiting** -```bash -hookdeck connection create \ - --source-name "high-volume-source" \ - --source-type HTTP \ - --destination-name "rate-limited-api" \ - --destination-type HTTP \ - --destination-url "https://api.example.com/endpoint" \ - --destination-rate-limit 100 \ - --destination-rate-limit-period minute -``` +**Usage:** -**11. GCP Service Account Authentication** ```bash -hookdeck connection create \ - --source-name "webhooks" \ - --source-type HTTP \ - --destination-name "gcp-cloud-function" \ - --destination-type HTTP \ - --destination-url "https://us-central1-project-id.cloudfunctions.net/function" \ - --destination-auth-method gcp \ - --destination-gcp-service-account-key '{"type":"service_account","project_id":"project-id","private_key_id":"key-id","private_key":"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n","client_email":"service-account@project-id.iam.gserviceaccount.com"}' \ - --destination-gcp-scope "https://www.googleapis.com/auth/cloud-platform" -``` - -#### Available Flags - -**Connection Configuration:** -- `--name ` - Connection name (optional, auto-generated if not provided) -- `--description ` - Connection description - -**Source (Inline Creation):** -- `--source-name ` - Source name (required for inline) -- `--source-type ` - Source type: `STRIPE`, `GITHUB`, `SHOPIFY`, `HTTP`, etc. -- `--source-description ` - Source description -- `--source-webhook-secret ` - Webhook verification secret -- `--source-api-key ` - API key authentication -- `--source-basic-auth-user ` - Basic auth username -- `--source-basic-auth-pass ` - Basic auth password -- `--source-hmac-secret ` - HMAC secret -- `--source-hmac-algo ` - HMAC algorithm -- `--source-allowed-http-methods ` - Comma-separated list of allowed HTTP methods: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` -- `--source-custom-response-content-type ` - Custom response content type: `json`, `text`, `xml` -- `--source-custom-response-body ` - Custom response body (max 1000 chars) -- `--source-config ` - JSON authentication config -- `--source-config-file ` - Path to JSON config file - -**Destination (Inline Creation):** -- `--destination-name ` - Destination name (required for inline) -- `--destination-type ` - Destination type: `HTTP`, `MOCK`, etc. -- `--destination-description ` - Destination description -- `--destination-url ` - Destination URL (required for HTTP) -- `--destination-cli-path ` - CLI path (default: `/`) -- `--destination-path-forwarding-disabled ` - Disable path forwarding for HTTP destinations (default: false) -- `--destination-http-method ` - HTTP method for HTTP destinations: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` -- `--destination-auth-method ` - Authentication method: `hookdeck`, `bearer`, `basic`, `api_key`, `custom_signature`, `oauth2_client_credentials`, `oauth2_authorization_code`, `aws`, `gcp` -- `--destination-rate-limit ` - Rate limit (requests per period) -- `--destination-rate-limit-period ` - Period: `second`, `minute`, `hour`, `day`, `month`, `year` - -**Destination Authentication Options:** - -*Hookdeck Signature (default):* -- `--destination-auth-method hookdeck` - Use Hookdeck signature authentication - -*Bearer Token:* -- `--destination-auth-method bearer` -- `--destination-bearer-token ` - Bearer token - -*Basic Authentication:* -- `--destination-auth-method basic` -- `--destination-basic-auth-user ` - Username -- `--destination-basic-auth-pass ` - Password - -*API Key:* -- `--destination-auth-method api_key` -- `--destination-api-key ` - API key -- `--destination-api-key-header ` - Key/header name -- `--destination-api-key-to ` - Location: `header` or `query` (default: `header`) - -*Custom Signature (HMAC):* -- `--destination-auth-method custom_signature` -- `--destination-custom-signature-key ` - Key/header name -- `--destination-custom-signature-secret ` - Signing secret - -*OAuth2 Client Credentials:* -- `--destination-auth-method oauth2_client_credentials` -- `--destination-oauth2-auth-server ` - Authorization server URL -- `--destination-oauth2-client-id ` - Client ID -- `--destination-oauth2-client-secret ` - Client secret -- `--destination-oauth2-scopes ` - Scopes (comma-separated, optional) -- `--destination-oauth2-auth-type ` - Auth type: `basic`, `bearer`, or `x-www-form-urlencoded` (default: `basic`) - -*OAuth2 Authorization Code:* -- `--destination-auth-method oauth2_authorization_code` -- `--destination-oauth2-auth-server ` - Authorization server URL -- `--destination-oauth2-client-id ` - Client ID -- `--destination-oauth2-client-secret ` - Client secret -- `--destination-oauth2-refresh-token ` - Refresh token -- `--destination-oauth2-scopes ` - Scopes (comma-separated, optional) - -*AWS Signature:* -- `--destination-auth-method aws` -- `--destination-aws-access-key-id ` - AWS access key ID -- `--destination-aws-secret-access-key ` - AWS secret access key -- `--destination-aws-region ` - AWS region -- `--destination-aws-service ` - AWS service name - -*GCP Service Account:* -- `--destination-auth-method gcp` -- `--destination-gcp-service-account-key ` - GCP service account key JSON -- `--destination-gcp-scope ` - GCP scope (optional) - -**Rules - Retry:** -- `--rule-retry-strategy ` - Strategy: `linear`, `exponential` -- `--rule-retry-count ` - Number of retry attempts (1-20) -- `--rule-retry-interval ` - Interval in milliseconds -- `--rule-retry-response-status-codes ` - Comma-separated status codes - -**Rules - Filter:** -- `--rule-filter-body ` - Body filter (JSON format) -- `--rule-filter-headers ` - Header filter (JSON format) -- `--rule-filter-path ` - Path filter (JSON format) -- `--rule-filter-query ` - Query parameter filter (JSON format) - -**Rules - Transform:** -- `--rule-transform-name ` - Name or ID of transformation - -**Rules - Delay:** -- `--rule-delay ` - Delay in milliseconds - -**Rules - Deduplicate:** -- `--rule-deduplicate-window ` - Deduplication window -- `--rule-deduplicate-include-fields ` - Comma-separated fields to include -- `--rule-deduplicate-exclude-fields ` - Comma-separated fields to exclude - -**Reference Existing Resources:** -- `--source ` - Use existing source -- `--destination ` - Use existing destination - -**JSON Fallbacks:** -- `--rules ` - Complete rules array (JSON string) -- `--rules-file ` - Path to JSON file with rules - -### Upsert Connection - -Create or update a connection idempotently based on the connection name. Perfect for CI/CD and infrastructure-as-code workflows. - -```bash -# Create if doesn't exist -hookdeck connection upsert my-connection \ - --source-name "stripe-prod" \ - --source-type STRIPE \ - --destination-name "api-prod" \ - --destination-type HTTP \ - --destination-url "https://api.example.com" - -# Update existing (partial update) -hookdeck connection upsert my-connection \ - --description "Updated description" \ - --rule-retry-count 5 - -# Preview changes without applying -hookdeck connection upsert my-connection \ - --description "New description" \ - --dry-run +hookdeck gateway destination get [flags] ``` -**Behavior:** -- If connection doesn't exist β†’ Creates it (source/destination required) -- If connection exists β†’ Updates it (all flags optional, partial updates) -- Supports all same flags as `connection create` -- Add `--dry-run` to preview CREATE or UPDATE operation +**Flags:** -**Use Cases:** -- CI/CD pipelines -- Infrastructure-as-code -- Idempotent configuration management +| Flag | Type | Description | +|------|------|-------------| +| `--include-auth` | `bool` | Include authentication credentials in the response | +| `--output` | `string` | Output format (json) | -### Delete Connection +**Examples:** ```bash -# Delete with confirmation prompt -hookdeck connection delete conn_abc123 - -# Delete by name -hookdeck connection delete "my-connection" - -# Skip confirmation -hookdeck connection delete conn_abc123 --force +hookdeck gateway destination get des_abc123 +hookdeck gateway destination get my-destination --include-auth ``` +### hookdeck gateway destination update -### Lifecycle Management +Update an existing destination by its ID. -Control connection state and processing behavior. +**Usage:** ```bash -# Enable/Disable (stop receiving events) -hookdeck connection disable conn_abc123 -hookdeck connection enable conn_abc123 - -# Pause/Unpause (queue events without forwarding) -hookdeck connection pause conn_abc123 -hookdeck connection unpause conn_abc123 +hookdeck gateway destination update [flags] ``` -**State Differences:** -- **Disabled**: Connection stops receiving events entirely -- **Paused**: Connection queues events but doesn't forward them +**Flags:** -### Implementation Notes +| Flag | Type | Description | +|------|------|-------------| +| `--api-key-header` | `string` | Header/key name for API key | +| `--api-key-to` | `string` | Where to send API key (header or query) (default "header") | +| `--auth-method` | `string` | Auth method (hookdeck, bearer, basic, api_key, custom_signature) | +| `--basic-auth-pass` | `string` | Password for Basic auth | +| `--basic-auth-user` | `string` | Username for Basic auth | +| `--bearer-token` | `string` | Bearer token for destination auth | +| `--cli-path` | `string` | Path for CLI destinations | +| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | +| `--custom-signature-key` | `string` | Key/header name for custom signature | +| `--custom-signature-secret` | `string` | Signing secret for custom signature | +| `--description` | `string` | New destination description | +| `--http-method` | `string` | HTTP method for HTTP destinations | +| `--name` | `string` | New destination name | +| `--output` | `string` | Output format (json) | +| `--rate-limit` | `int` | Rate limit (requests per period) (default "0") | +| `--rate-limit-period` | `string` | Rate limit period (second, minute, hour, concurrent) | +| `--type` | `string` | Destination type (HTTP, CLI, MOCK_API) | +| `--url` | `string` | URL for HTTP destinations | -**Fully Implemented (βœ…):** -- Full CRUD operations (create, list, get, upsert, delete) -- Inline resource creation with authentication -- All 5 rule types (retry, filter, transform, delay, deduplicate) -- Rate limiting configuration -- Lifecycle management (enable, disable, pause, unpause) -- Idempotent upsert with dry-run support -- 21 acceptance tests, all passing +**Examples:** -**Not Implemented (❌):** -- `connection count` command (optional) -- Bulk operations (planned) -- Connection cloning (optional) +```bash +hookdeck gateway destination update des_abc123 --name new-name +hookdeck gateway destination update des_abc123 --description "Updated" +hookdeck gateway destination update des_abc123 --url https://api.example.com/new +``` +### hookdeck gateway destination delete -**See Also:** -- [Connection Management Status](.plans/connection-management-status.md) +Delete a destination. -## Transformations +**Usage:** -**All Parameters:** ```bash -# Transformation list command parameters ---name string # Filter by name pattern (supports wildcards) ---limit integer # Limit number of results (default varies) - -# Transformation count command parameters ---name string # Filter by name pattern - -# Transformation get command parameters - # Required positional argument for transformation ID +hookdeck gateway destination delete [flags] +``` -# Transformation create command parameters ---name string # Required: Transformation name ---code string # Required: JavaScript code for the transformation ---description string # Optional: Transformation description ---env string # Optional: Environment variables (KEY=value,KEY2=value2) +**Flags:** -# Transformation update command parameters - # Required positional argument for transformation ID ---name string # Update transformation name ---code string # Update JavaScript code ---description string # Update transformation description ---env string # Update environment variables (KEY=value,KEY2=value2) +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Force delete without confirmation | -# Transformation upsert command parameters (create or update by name) ---name string # Required: Transformation name (used for matching existing) ---code string # Required: JavaScript code ---description string # Optional: Transformation description ---env string # Optional: Environment variables +**Examples:** -# Transformation delete command parameters - # Required positional argument for transformation ID ---force # Force delete without confirmation (boolean flag) +```bash +hookdeck gateway destination delete des_abc123 +hookdeck gateway destination delete des_abc123 --force +``` +### hookdeck gateway destination upsert -# Transformation run command parameters (testing) ---code string # Required: JavaScript code to test ---request string # Required: Request JSON for testing +Create a new destination or update an existing one by name (idempotent). -# Transformation executions command parameters - # Required positional argument for transformation ID ---limit integer # Limit number of execution results +**Usage:** -# Transformation execution command parameters (get single execution) - # Required positional argument for transformation ID - # Required positional argument for execution ID +```bash +hookdeck gateway destination upsert [flags] ``` -**Environment Variables Format:** -- Use comma-separated key=value pairs: `KEY1=value1,KEY2=value2` -- Supports debugging flags: `DEBUG=true,LOG_LEVEL=info` -- Can reference external services: `API_URL=https://api.example.com,API_KEY=secret` +**Flags:** -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +| Flag | Type | Description | +|------|------|-------------| +| `--api-key-header` | `string` | Header/key name for API key | +| `--api-key-to` | `string` | Where to send API key (header or query) (default "header") | +| `--auth-method` | `string` | Auth method (hookdeck, bearer, basic, api_key, custom_signature) | +| `--basic-auth-pass` | `string` | Password for Basic auth | +| `--basic-auth-user` | `string` | Username for Basic auth | +| `--bearer-token` | `string` | Bearer token for destination auth | +| `--cli-path` | `string` | Path for CLI destinations | +| `--config-file` | `string` | Path to JSON file for destination config (overrides individual flags if set) | +| `--custom-signature-key` | `string` | Key/header name for custom signature | +| `--custom-signature-secret` | `string` | Signing secret for custom signature | +| `--description` | `string` | Destination description | +| `--dry-run` | `bool` | Preview changes without applying | +| `--http-method` | `string` | HTTP method for HTTP destinations | +| `--output` | `string` | Output format (json) | +| `--rate-limit` | `int` | Rate limit (requests per period) (default "0") | +| `--rate-limit-period` | `string` | Rate limit period (second, minute, hour, concurrent) | +| `--type` | `string` | Destination type (HTTP, CLI, MOCK_API) | +| `--url` | `string` | URL for HTTP destinations | -Transformations allow you to modify webhook payloads using JavaScript. +**Examples:** -### List transformations ```bash -# List all transformations -hookdeck transformation list - -# Filter by name pattern -hookdeck transformation list --name "*stripe*" - -# Limit results -hookdeck transformation list --limit 50 +hookdeck gateway destination upsert my-api --type HTTP --url https://api.example.com/webhooks +hookdeck gateway destination upsert local-cli --type CLI --cli-path /webhooks +hookdeck gateway destination upsert my-api --description "Updated" --dry-run ``` +### hookdeck gateway destination count -### Count transformations -```bash -# Count all transformations -hookdeck transformation count +Count destinations matching optional filters. -# Count with filters -hookdeck transformation count --name "*formatter*" -``` +**Usage:** -### Get transformation details ```bash -# Get transformation by ID -hookdeck transformation get +hookdeck gateway destination count [flags] ``` -### Create a transformation -```bash -# Create with interactive prompts -hookdeck transformation create - -# Create with inline code -hookdeck transformation create --name "stripe-formatter" \ - --code 'export default function(request) { - request.body.processed_at = new Date().toISOString(); - request.body.webhook_source = "stripe"; - return request; - }' +**Flags:** -# Create with environment variables -hookdeck transformation create --name "api-enricher" \ - --code 'export default function(request) { - const { API_KEY } = process.env; - request.headers["X-API-Key"] = API_KEY; - return request; - }' \ - --env "API_KEY=your_key,DEBUG=true" +| Flag | Type | Description | +|------|------|-------------| +| `--disabled` | `bool` | Count disabled destinations only (when set with other filters) | +| `--name` | `string` | Filter by destination name | +| `--type` | `string` | Filter by destination type (HTTP, CLI, MOCK_API) | -# Create with description -hookdeck transformation create --name "payment-processor" \ - --description "Processes payment webhooks and adds metadata" \ - --code 'export default function(request) { - if (request.body.type?.includes("payment")) { - request.body.category = "payment"; - request.body.priority = "high"; - } - return request; - }' -``` +**Examples:** -### Update a transformation ```bash -# Update transformation code -hookdeck transformation update \ - --code 'export default function(request) { /* updated code */ return request; }' +hookdeck gateway destination count +hookdeck gateway destination count --type HTTP +hookdeck gateway destination count --disabled +``` +### hookdeck gateway destination enable -# Update name and description -hookdeck transformation update --name "new-name" --description "Updated description" +Enable a disabled destination. -# Update environment variables -hookdeck transformation update --env "API_KEY=new_key,DEBUG=false" -``` +**Usage:** -### Upsert a transformation (create or update by name) ```bash -# Create or update transformation by name -hookdeck transformation upsert --name "stripe-formatter" \ - --code 'export default function(request) { return request; }' +hookdeck gateway destination enable ``` +### hookdeck gateway destination disable -### Delete a transformation -```bash -# Delete transformation (with confirmation) -hookdeck transformation delete +Disable an active destination. It will stop receiving new events until re-enabled. -# Force delete without confirmation -hookdeck transformation delete --force -``` +**Usage:** -### Test a transformation ```bash -# Test with sample request JSON -hookdeck transformation run --code 'export default function(request) { return request; }' \ - --request '{"headers": {"content-type": "application/json"}, "body": {"test": true}}' +hookdeck gateway destination disable ``` + +## Transformations -### Get transformation executions -```bash -# List executions for a transformation -hookdeck transformation executions --limit 50 + +- [hookdeck gateway transformation list](#hookdeck-gateway-transformation-list) +- [hookdeck gateway transformation create](#hookdeck-gateway-transformation-create) +- [hookdeck gateway transformation get](#hookdeck-gateway-transformation-get) +- [hookdeck gateway transformation update](#hookdeck-gateway-transformation-update) +- [hookdeck gateway transformation delete](#hookdeck-gateway-transformation-delete) +- [hookdeck gateway transformation upsert](#hookdeck-gateway-transformation-upsert) +- [hookdeck gateway transformation run](#hookdeck-gateway-transformation-run) +- [hookdeck gateway transformation count](#hookdeck-gateway-transformation-count) +- [hookdeck gateway transformation executions list](#hookdeck-gateway-transformation-executions-list) +- [hookdeck gateway transformation executions get](#hookdeck-gateway-transformation-executions-get) -# Get specific execution details -hookdeck transformation execution -``` +### hookdeck gateway transformation list -## Events +List all transformations or filter by name or id. + +**Usage:** -**All Parameters:** ```bash -# Event list command parameters ---id string # Filter by event IDs (comma-separated) ---status string # Filter by status (SUCCESSFUL, FAILED, PENDING) ---webhook-id string # Filter by webhook ID (connection) ---destination-id string # Filter by destination ID ---source-id string # Filter by source ID ---attempts integer # Filter by number of attempts (minimum: 0) ---response-status integer # Filter by HTTP response status (200-600) ---successful-at string # Filter by success date (ISO date-time) ---created-at string # Filter by creation date (ISO date-time) ---error-code string # Filter by error code ---cli-id string # Filter by CLI ID ---last-attempt-at string # Filter by last attempt date (ISO date-time) ---search-term string # Search in body/headers/path (minimum 3 characters) ---headers string # Header matching (JSON string) ---body string # Body matching (JSON string) ---parsed-query string # Query parameter matching (JSON string) ---path string # Path matching ---order-by string # Sort by: created_at ---dir string # Sort direction: asc, desc ---limit integer # Limit number of results (0-255) ---next string # Next page token for pagination ---prev string # Previous page token for pagination +hookdeck gateway transformation list [flags] +``` -# Event get command parameters - # Required positional argument for event ID +**Flags:** -# Event raw-body command parameters - # Required positional argument for event ID +| Flag | Type | Description | +|------|------|-------------| +| `--dir` | `string` | Sort direction (asc, desc) | +| `--id` | `string` | Filter by transformation ID(s) | +| `--limit` | `int` | Limit number of results (default "100") | +| `--name` | `string` | Filter by transformation name | +| `--next` | `string` | Pagination cursor for next page | +| `--order-by` | `string` | Sort key (name, created_at, updated_at) | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Pagination cursor for previous page | -# Event retry command parameters - # Required positional argument for event ID +**Examples:** -# Event mute command parameters - # Required positional argument for event ID +```bash +hookdeck gateway transformation list +hookdeck gateway transformation list --name my-transform +hookdeck gateway transformation list --order-by created_at --dir desc +hookdeck gateway transformation list --limit 10 ``` +### hookdeck gateway transformation create -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +Create a new transformation. -### List events -```bash -# List recent events -hookdeck event list +Requires `--name` and `--code` (or `--code-file`). Use `--env` for key-value environment variables. -# Filter by webhook ID (connection) -hookdeck event list --webhook-id +**Usage:** -# Filter by source ID -hookdeck event list --source-id +```bash +hookdeck gateway transformation create [flags] +``` -# Filter by destination ID -hookdeck event list --destination-id +**Flags:** -# Filter by status -hookdeck event list --status SUCCESSFUL -hookdeck event list --status FAILED -hookdeck event list --status PENDING +| Flag | Type | Description | +|------|------|-------------| +| `--code` | `string` | JavaScript code string (required if `--code-file` not set) | +| `--code-file` | `string` | Path to JavaScript file (required if `--code` not set) | +| `--env` | `string` | Environment variables as KEY=value,KEY2=value2 | +| `--name` | `string` | Transformation name (required) | +| `--output` | `string` | Output format (json) | -# Limit results -hookdeck event list --limit 100 +**Examples:** -# Combined filtering -hookdeck event list --webhook-id --status FAILED --limit 50 +```bash +hookdeck gateway transformation create --name my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" +hookdeck gateway transformation create --name my-transform --code-file ./transform.js --env FOO=bar,BAZ=qux ``` +### hookdeck gateway transformation get -### Get event details -```bash -# Get event by ID -hookdeck event get +Get detailed information about a specific transformation. -# Get event raw body -hookdeck event raw-body -``` +You can specify either a transformation ID or name. -### Retry events -```bash -# Retry single event -hookdeck event retry -``` +**Usage:** -### Mute events ```bash -# Mute event (stop retries) -hookdeck event mute +hookdeck gateway transformation get [flags] ``` -## Attempts +**Flags:** -**All Parameters:** -```bash -# Attempt list command parameters ---event-id string # Filter by specific event ID ---destination-id string # Filter by destination ID ---status string # Filter by attempt status (FAILED, SUCCESSFUL) ---trigger string # Filter by trigger type (INITIAL, MANUAL, BULK_RETRY, UNPAUSE, AUTOMATIC) ---error-code string # Filter by error code (TIMEOUT, CONNECTION_REFUSED, etc.) ---bulk-retry-id string # Filter by bulk retry operation ID ---successful-at string # Filter by success timestamp (ISO format or operators) ---delivered-at string # Filter by delivery timestamp (ISO format or operators) ---responded-at string # Filter by response timestamp (ISO format or operators) ---order-by string # Sort by field (created_at, delivered_at, responded_at) ---dir string # Sort direction (asc, desc) ---limit integer # Limit number of results (0-255) ---next string # Next page token for pagination ---prev string # Previous page token for pagination +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | -# Attempt get command parameters - # Required positional argument for attempt ID +**Examples:** -# Attempt retry command parameters - # Required positional argument for attempt ID to retry ---force # Force retry without confirmation (boolean flag) +```bash +hookdeck gateway transformation get trn_abc123 +hookdeck gateway transformation get my-transform ``` +### hookdeck gateway transformation update -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +Update an existing transformation by its ID. -Attempts represent individual delivery attempts for webhook events, including success/failure status, response details, and performance metrics. +**Usage:** -### List attempts ```bash -# List all attempts -hookdeck attempt list +hookdeck gateway transformation update [flags] +``` -# List attempts for a specific event -hookdeck attempt list --event-id evt_123 +**Flags:** -# List attempts for a destination -hookdeck attempt list --destination-id des_456 +| Flag | Type | Description | +|------|------|-------------| +| `--code` | `string` | New JavaScript code string | +| `--code-file` | `string` | Path to JavaScript file | +| `--env` | `string` | Environment variables as KEY=value,KEY2=value2 | +| `--name` | `string` | New transformation name | +| `--output` | `string` | Output format (json) | -# Filter by status -hookdeck attempt list --status FAILED -hookdeck attempt list --status SUCCESSFUL +**Examples:** -# Filter by trigger type -hookdeck attempt list --trigger MANUAL -hookdeck attempt list --trigger BULK_RETRY +```bash +hookdeck gateway transformation update trn_abc123 --name new-name +hookdeck gateway transformation update my-transform --code-file ./transform.js +hookdeck gateway transformation update trn_abc123 --env FOO=bar +``` +### hookdeck gateway transformation delete -# Filter by error code -hookdeck attempt list --error-code TIMEOUT -hookdeck attempt list --error-code CONNECTION_REFUSED +Delete a transformation. -# Filter by bulk retry operation -hookdeck attempt list --bulk-retry-id retry_789 +**Usage:** -# Filter by timestamp (various operators supported) -hookdeck attempt list --delivered-at "2024-01-01T00:00:00Z" -hookdeck attempt list --successful-at ">2024-01-01T00:00:00Z" +```bash +hookdeck gateway transformation delete [flags] +``` -# Sort and limit results -hookdeck attempt list --order-by delivered_at --dir desc --limit 100 +**Flags:** -# Pagination -hookdeck attempt list --limit 50 --next +| Flag | Type | Description | +|------|------|-------------| +| `--force` | `bool` | Force delete without confirmation | -# Combined filtering -hookdeck attempt list --event-id evt_123 --status FAILED --error-code TIMEOUT -``` +**Examples:** -### Get attempt details ```bash -# Get attempt by ID -hookdeck attempt get att_123 - -# Example output includes: -# - Attempt ID and number -# - Event and destination IDs -# - HTTP method and requested URL -# - Response status and body -# - Trigger type and error code -# - Delivery and response latency -# - Timestamps (delivered_at, responded_at, successful_at) +hookdeck gateway transformation delete trn_abc123 +hookdeck gateway transformation delete trn_abc123 --force ``` +### hookdeck gateway transformation upsert -### Retry attempts -```bash -# Retry a specific attempt -hookdeck attempt retry att_123 +Create a new transformation or update an existing one by name (idempotent). -# Force retry without confirmation -hookdeck attempt retry att_123 --force +**Usage:** -# Note: This creates a new attempt for the same event +```bash +hookdeck gateway transformation upsert [flags] ``` +**Flags:** -## Issues +| Flag | Type | Description | +|------|------|-------------| +| `--code` | `string` | JavaScript code string | +| `--code-file` | `string` | Path to JavaScript file | +| `--dry-run` | `bool` | Preview changes without applying | +| `--env` | `string` | Environment variables as KEY=value,KEY2=value2 | +| `--output` | `string` | Output format (json) | -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +**Examples:** -### List issues ```bash -# List all issues -hookdeck issue list +hookdeck gateway transformation upsert my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" +hookdeck gateway transformation upsert my-transform --code-file ./transform.js --env FOO=bar +hookdeck gateway transformation upsert my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" --dry-run +``` +### hookdeck gateway transformation run -# Filter by status -hookdeck issue list --status ACTIVE -hookdeck issue list --status DISMISSED +Test run transformation code against a sample request. -# Filter by type -hookdeck issue list --type DELIVERY_ISSUE -hookdeck issue list --type TRANSFORMATION_ISSUE +Provide either inline `--code`/`--code-file` or `--id` to use an existing transformation. +The `--request` or `--request-file` must be JSON with at least "headers" (can be {}). Optional: body, path, query. -# Limit results -hookdeck issue list --limit 100 -``` +**Usage:** -### Count issues ```bash -# Count all issues -hookdeck issue count - -# Count with filters -hookdeck issue count --status ACTIVE --type DELIVERY_ISSUE +hookdeck gateway transformation run [flags] ``` -### Get issue details -```bash -# Get issue by ID -hookdeck issue get -``` +**Flags:** -## Issue Triggers +| Flag | Type | Description | +|------|------|-------------| +| `--code` | `string` | JavaScript code string to run | +| `--code-file` | `string` | Path to JavaScript file | +| `--connection-id` | `string` | Connection ID for execution context | +| `--env` | `string` | Environment variables as KEY=value,KEY2=value2 | +| `--id` | `string` | Use existing transformation by ID | +| `--output` | `string` | Output format (json) | +| `--request` | `string` | Request JSON (must include headers, e.g. {"headers":{}}) | +| `--request-file` | `string` | Path to request JSON file | + +**Examples:** -**All Parameters:** ```bash -# Issue trigger list command parameters ---name string # Filter by name pattern (supports wildcards) ---type string # Filter by trigger type (delivery, transformation, backpressure) ---disabled # Include disabled triggers (boolean flag) ---limit integer # Limit number of results (default varies) +hookdeck gateway transformation run --id trs_abc123 --request '{"headers":{}}' +hookdeck gateway transformation run --code "addHandler(\"transform\", (request, context) => { return request; });" --request-file ./sample.json +hookdeck gateway transformation run --id trs_abc123 --request '{"headers":{},"body":{"foo":"bar"}}' --connection-id web_xxx +``` +### hookdeck gateway transformation count -# Issue trigger get command parameters - # Required positional argument for trigger ID +Count transformations matching optional filters. -# Issue trigger create command parameters ---name string # Optional: Unique name for the trigger ---type string # Required: Trigger type (delivery, transformation, backpressure) ---description string # Optional: Trigger description +**Usage:** -# Type-specific configuration parameters: -# When --type=delivery: ---strategy string # Required: Strategy (first_attempt, final_attempt) ---connections string # Required: Connection patterns or IDs (comma-separated or "*") +```bash +hookdeck gateway transformation count [flags] +``` -# When --type=transformation: ---log-level string # Required: Log level (debug, info, warn, error, fatal) ---transformations string # Required: Transformation patterns or IDs (comma-separated or "*") +**Flags:** -# When --type=backpressure: ---delay integer # Required: Minimum delay in milliseconds (60000-86400000) ---destinations string # Required: Destination patterns or IDs (comma-separated or "*") +| Flag | Type | Description | +|------|------|-------------| +| `--name` | `string` | Filter by transformation name | +| `--output` | `string` | Output format (json) | -# Notification channel parameters (at least one required): ---email # Enable email notifications (boolean flag) ---slack-channel string # Slack channel name (e.g., "#alerts") ---pagerduty # Enable PagerDuty notifications (boolean flag) ---opsgenie # Enable Opsgenie notifications (boolean flag) +**Examples:** -# Issue trigger update command parameters - # Required positional argument for trigger ID ---name string # Update trigger name ---description string # Update trigger description -# Plus any type-specific and notification parameters listed above +```bash +hookdeck gateway transformation count +hookdeck gateway transformation count --name my-transform +``` +### hookdeck gateway transformation executions list -# Issue trigger upsert command parameters (create or update by name) ---name string # Required: Trigger name (used for matching existing) ---type string # Required: Trigger type -# Plus any type-specific and notification parameters listed above +List executions for a transformation. -# Issue trigger delete command parameters - # Required positional argument for trigger ID ---force # Force delete without confirmation (boolean flag) +**Usage:** -# Issue trigger enable/disable command parameters - # Required positional argument for trigger ID +```bash +hookdeck gateway transformation executions list [flags] ``` -**Type Validation Rules:** -- **delivery type**: Requires `--strategy` and `--connections` - - `--strategy` values: `first_attempt`, `final_attempt` - - `--connections` accepts: connection IDs, connection name patterns, or `"*"` for all -- **transformation type**: Requires `--log-level` and `--transformations` - - `--log-level` values: `debug`, `info`, `warn`, `error`, `fatal` - - `--transformations` accepts: transformation IDs, transformation name patterns, or `"*"` for all -- **backpressure type**: Requires `--delay` and `--destinations` - - `--delay` range: 60000-86400000 milliseconds (1 minute to 1 day) - - `--destinations` accepts: destination IDs, destination name patterns, or `"*"` for all +**Flags:** -**Notification Channel Combinations:** -- Multiple notification channels can be enabled simultaneously -- `--email` is a boolean flag (no additional configuration) -- `--slack-channel` requires a channel name (e.g., "#alerts", "#monitoring") -- `--pagerduty` and `--opsgenie` are boolean flags requiring pre-configured integrations +| Flag | Type | Description | +|------|------|-------------| +| `--connection-id` | `string` | Filter by connection ID | +| `--created-at` | `string` | Filter by created_at (ISO date or operator) | +| `--dir` | `string` | Sort direction (asc, desc) | +| `--issue-id` | `string` | Filter by issue ID | +| `--limit` | `int` | Limit number of results (default "100") | +| `--next` | `string` | Pagination cursor for next page | +| `--order-by` | `string` | Sort key (created_at) | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Pagination cursor for previous page | +### hookdeck gateway transformation executions get -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +Get a single execution by transformation ID and execution ID. -Issue triggers automatically detect and create issues when specific conditions are met. +**Usage:** -### List issue triggers ```bash -# List all issue triggers -hookdeck issue-trigger list +hookdeck gateway transformation executions get [flags] +``` -# Filter by name pattern -hookdeck issue-trigger list --name "*delivery*" +**Flags:** -# Filter by type -hookdeck issue-trigger list --type delivery -hookdeck issue-trigger list --type transformation -hookdeck issue-trigger list --type backpressure +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +## Events -# Include disabled triggers -hookdeck issue-trigger list --disabled + +- [hookdeck gateway event list](#hookdeck-gateway-event-list) +- [hookdeck gateway event get](#hookdeck-gateway-event-get) +- [hookdeck gateway event retry](#hookdeck-gateway-event-retry) +- [hookdeck gateway event cancel](#hookdeck-gateway-event-cancel) +- [hookdeck gateway event mute](#hookdeck-gateway-event-mute) +- [hookdeck gateway event raw-body](#hookdeck-gateway-event-raw-body) -# Limit results -hookdeck issue-trigger list --limit 50 -``` +### hookdeck gateway event list + +List events (processed webhook deliveries). Filter by connection ID, source, destination, or status. + +**Usage:** -### Get issue trigger details ```bash -# Get issue trigger by ID -hookdeck issue-trigger get +hookdeck gateway event list [flags] ``` -### Create issue triggers +**Flags:** -#### Delivery failure trigger -```bash -# Trigger on final delivery attempt failure -hookdeck issue-trigger create --type delivery \ - --name "delivery-failures" \ - --strategy final_attempt \ - --connections "conn1,conn2" \ - --email \ - --slack-channel "#alerts" +| Flag | Type | Description | +|------|------|-------------| +| `--attempts` | `string` | Filter by number of attempts (integer or operators) | +| `--body` | `string` | Filter by body (JSON string) | +| `--cli-id` | `string` | Filter by CLI ID | +| `--connection-id` | `string` | Filter by connection ID | +| `--created-after` | `string` | Filter events created after (ISO date-time) | +| `--created-before` | `string` | Filter events created before (ISO date-time) | +| `--destination-id` | `string` | Filter by destination ID | +| `--dir` | `string` | Sort direction (asc, desc) | +| `--error-code` | `string` | Filter by error code | +| `--headers` | `string` | Filter by headers (JSON string) | +| `--id` | `string` | Filter by event ID(s) (comma-separated) | +| `--issue-id` | `string` | Filter by issue ID | +| `--last-attempt-at-after` | `string` | Filter by last_attempt_at after (ISO date-time) | +| `--last-attempt-at-before` | `string` | Filter by last_attempt_at before (ISO date-time) | +| `--limit` | `int` | Limit number of results (default "100") | +| `--next` | `string` | Pagination cursor for next page | +| `--order-by` | `string` | Sort key (e.g. created_at) | +| `--output` | `string` | Output format (json) | +| `--parsed-query` | `string` | Filter by parsed query (JSON string) | +| `--path` | `string` | Filter by path | +| `--prev` | `string` | Pagination cursor for previous page | +| `--response-status` | `string` | Filter by HTTP response status (e.g. 200, 500) | +| `--source-id` | `string` | Filter by source ID | +| `--status` | `string` | Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED) | +| `--successful-at-after` | `string` | Filter by successful_at after (ISO date-time) | +| `--successful-at-before` | `string` | Filter by successful_at before (ISO date-time) | -# Trigger on first delivery attempt failure -hookdeck issue-trigger create --type delivery \ - --name "immediate-delivery-alerts" \ - --strategy first_attempt \ - --connections "*" \ - --pagerduty -``` +**Examples:** -#### Transformation error trigger ```bash -# Trigger on transformation errors -hookdeck issue-trigger create --type transformation \ - --name "transformation-errors" \ - --log-level error \ - --transformations "*" \ - --email \ - --opsgenie - -# Trigger on specific transformation debug logs -hookdeck issue-trigger create --type transformation \ - --name "debug-logs" \ - --log-level debug \ - --transformations "trans1,trans2" \ - --slack-channel "#debug" +hookdeck gateway event list +hookdeck gateway event list --connection-id web_abc123 +hookdeck gateway event list --status FAILED --limit 20 ``` +### hookdeck gateway event get + +Get detailed information about an event by ID. + +**Usage:** -#### Backpressure trigger ```bash -# Trigger on destination backpressure -hookdeck issue-trigger create --type backpressure \ - --name "backpressure-alert" \ - --delay 300000 \ - --destinations "*" \ - --email \ - --pagerduty +hookdeck gateway event get [flags] ``` -### Update issue trigger -```bash -# Update trigger name and description -hookdeck issue-trigger update --name "new-name" --description "Updated description" +**Flags:** -# Update notification channels -hookdeck issue-trigger update --email --slack-channel "#new-alerts" +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | -# Update type-specific configuration -hookdeck issue-trigger update --strategy first_attempt --connections "new_conn" -``` +**Examples:** -### Upsert issue trigger (create or update by name) ```bash -# Create or update issue trigger by name -hookdeck issue-trigger upsert --name "delivery-failures" --type delivery --strategy final_attempt +hookdeck gateway event get evt_abc123 ``` +### hookdeck gateway event retry -### Delete issue trigger -```bash -# Delete issue trigger (with confirmation) -hookdeck issue-trigger delete +Retry delivery for an event by ID. -# Force delete without confirmation -hookdeck issue-trigger delete --force -``` +**Usage:** -### Enable/Disable issue triggers ```bash -# Enable issue trigger -hookdeck issue-trigger enable - -# Disable issue trigger -hookdeck issue-trigger disable +hookdeck gateway event retry ``` -## Bookmarks +**Examples:** -**All Parameters:** ```bash -# Bookmark list command parameters ---name string # Filter by name pattern (supports wildcards) ---webhook-id string # Filter by webhook ID (connection) ---label string # Filter by label ---limit integer # Limit number of results (default varies) - -# Bookmark get command parameters - # Required positional argument for bookmark ID +hookdeck gateway event retry evt_abc123 +``` +### hookdeck gateway event cancel -# Bookmark raw-body command parameters - # Required positional argument for bookmark ID +Cancel an event by ID. Cancelled events will not be retried. -# Bookmark create command parameters ---event-data-id string # Required: Event data ID to bookmark ---webhook-id string # Required: Webhook ID (connection) ---label string # Required: Label for categorization ---name string # Optional: Bookmark name +**Usage:** -# Bookmark update command parameters - # Required positional argument for bookmark ID ---name string # Update bookmark name ---label string # Update bookmark label +```bash +hookdeck gateway event cancel +``` -# Bookmark delete command parameters - # Required positional argument for bookmark ID ---force # Force delete without confirmation (boolean flag) +**Examples:** -# Bookmark trigger command parameters (replay) - # Required positional argument for bookmark ID +```bash +hookdeck gateway event cancel evt_abc123 ``` +### hookdeck gateway event mute -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +Mute an event by ID. Muted events will not trigger alerts or retries. -Bookmarks allow you to save webhook payloads for testing and replay. +**Usage:** -### List bookmarks ```bash -# List all bookmarks -hookdeck bookmark list - -# Filter by name pattern -hookdeck bookmark list --name "*test*" - -# Filter by webhook ID (connection) -hookdeck bookmark list --webhook-id +hookdeck gateway event mute +``` -# Filter by label -hookdeck bookmark list --label test_data +**Examples:** -# Limit results -hookdeck bookmark list --limit 50 +```bash +hookdeck gateway event mute evt_abc123 ``` +### hookdeck gateway event raw-body -### Get bookmark details -```bash -# Get bookmark by ID -hookdeck bookmark get +Output the raw request body of an event by ID. -# Get bookmark raw body -hookdeck bookmark raw-body -``` +**Usage:** -### Create a bookmark ```bash -# Create bookmark from event -hookdeck bookmark create --event-data-id \ - --webhook-id \ - --label test_payload \ - --name "stripe-payment-test" +hookdeck gateway event raw-body ``` -### Update a bookmark +**Examples:** + ```bash -# Update bookmark properties -hookdeck bookmark update --name "new-name" --label new_label +hookdeck gateway event raw-body evt_abc123 ``` + +## Requests -### Delete a bookmark -```bash -# Delete bookmark (with confirmation) -hookdeck bookmark delete + +- [hookdeck gateway request list](#hookdeck-gateway-request-list) +- [hookdeck gateway request get](#hookdeck-gateway-request-get) +- [hookdeck gateway request retry](#hookdeck-gateway-request-retry) +- [hookdeck gateway request events](#hookdeck-gateway-request-events) +- [hookdeck gateway request ignored-events](#hookdeck-gateway-request-ignored-events) +- [hookdeck gateway request raw-body](#hookdeck-gateway-request-raw-body) -# Force delete without confirmation -hookdeck bookmark delete --force -``` +### hookdeck gateway request list + +List requests (raw inbound webhooks). Filter by source ID. + +**Usage:** -### Trigger bookmark (replay) ```bash -# Trigger bookmark to replay webhook -hookdeck bookmark trigger +hookdeck gateway request list [flags] ``` -## Integrations +**Flags:** -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +| Flag | Type | Description | +|------|------|-------------| +| `--body` | `string` | Filter by body (JSON string) | +| `--created-after` | `string` | Filter requests created after (ISO date-time) | +| `--created-before` | `string` | Filter requests created before (ISO date-time) | +| `--dir` | `string` | Sort direction (asc, desc) | +| `--headers` | `string` | Filter by headers (JSON string) | +| `--id` | `string` | Filter by request ID(s) (comma-separated) | +| `--ingested-at-after` | `string` | Filter by ingested_at after (ISO date-time) | +| `--ingested-at-before` | `string` | Filter by ingested_at before (ISO date-time) | +| `--limit` | `int` | Limit number of results (default "100") | +| `--next` | `string` | Pagination cursor for next page | +| `--order-by` | `string` | Sort key (e.g. created_at) | +| `--output` | `string` | Output format (json) | +| `--parsed-query` | `string` | Filter by parsed query (JSON string) | +| `--path` | `string` | Filter by path | +| `--prev` | `string` | Pagination cursor for previous page | +| `--rejection-cause` | `string` | Filter by rejection cause | +| `--source-id` | `string` | Filter by source ID | +| `--status` | `string` | Filter by status | +| `--verified` | `string` | Filter by verified (true/false) | -Integrations connect third-party services to your Hookdeck workspace. +**Examples:** -### List integrations ```bash -# List all integrations -hookdeck integration list - -# Limit results -hookdeck integration list --limit 50 +hookdeck gateway request list +hookdeck gateway request list --source-id src_abc123 --limit 20 ``` +### hookdeck gateway request get -### Get integration details -```bash -# Get integration by ID -hookdeck integration get -``` +Get detailed information about a request by ID. + +**Usage:** -### Create an integration ```bash -# Create integration (provider-specific configuration required) -hookdeck integration create --provider PROVIDER_NAME +hookdeck gateway request get [flags] ``` -### Update an integration +**Flags:** + +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | + +**Examples:** + ```bash -# Update integration (provider-specific configuration) -hookdeck integration update +hookdeck gateway request get req_abc123 ``` +### hookdeck gateway request retry -### Delete an integration -```bash -# Delete integration (with confirmation) -hookdeck integration delete +Retry a request by ID. By default retries on all connections. Use `--connection-ids` to retry only for specific connections. -# Force delete without confirmation -hookdeck integration delete --force -``` +**Usage:** -### Attach/Detach sources ```bash -# Attach source to integration -hookdeck integration attach - -# Detach source from integration -hookdeck integration detach +hookdeck gateway request retry [flags] ``` -## Requests +**Flags:** -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +| Flag | Type | Description | +|------|------|-------------| +| `--connection-ids` | `string` | Comma-separated connection IDs to retry (omit to retry all) | -Requests represent raw incoming webhook requests before processing. +**Examples:** -### List requests ```bash -# List all requests -hookdeck request list - -# Filter by source ID -hookdeck request list --source-id +hookdeck gateway request retry req_abc123 +hookdeck gateway request retry req_abc123 --connection-ids web_1,web_2 +``` +### hookdeck gateway request events -# Filter by verification status -hookdeck request list --verified true -hookdeck request list --verified false +List events (deliveries) created from a request. -# Filter by rejection cause -hookdeck request list --rejection-cause INVALID_SIGNATURE +**Usage:** -# Limit results -hookdeck request list --limit 100 +```bash +hookdeck gateway request events [flags] ``` -### Get request details -```bash -# Get request by ID -hookdeck request get +**Flags:** -# Get request raw body -hookdeck request raw-body -``` +| Flag | Type | Description | +|------|------|-------------| +| `--limit` | `int` | Limit number of results (default "100") | +| `--next` | `string` | Pagination cursor for next page | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Pagination cursor for previous page | + +**Examples:** -### Retry request ```bash -# Retry request processing -hookdeck request retry +hookdeck gateway request events req_abc123 ``` +### hookdeck gateway request ignored-events -### List request events -```bash -# List events generated from request -hookdeck request events --limit 50 +List ignored events for a request (e.g. filtered out or deduplicated). -# List ignored events from request -hookdeck request ignored-events --limit 50 +**Usage:** + +```bash +hookdeck gateway request ignored-events [flags] ``` -## Bulk Operations +**Flags:** -**All Parameters:** -```bash -# Bulk event-retry command parameters ---limit integer # Limit number of results for list operations ---query string # JSON query for filtering resources to retry - # Required positional argument for get/cancel operations +| Flag | Type | Description | +|------|------|-------------| +| `--limit` | `int` | Limit number of results (default "100") | +| `--next` | `string` | Pagination cursor for next page | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Pagination cursor for previous page | -# Bulk request-retry command parameters ---limit integer # Limit number of results for list operations ---query string # JSON query for filtering resources to retry - # Required positional argument for get/cancel operations +**Examples:** -# Bulk ignored-event-retry command parameters ---limit integer # Limit number of results for list operations ---query string # JSON query for filtering resources to retry - # Required positional argument for get/cancel operations +```bash +hookdeck gateway request ignored-events req_abc123 ``` +### hookdeck gateway request raw-body -**Query JSON Format Examples:** -- Event retry: `'{"status": "FAILED", "webhook_id": "conn_123"}'` -- Request retry: `'{"verified": false, "source_id": "src_123"}'` -- Ignored event retry: `'{"webhook_id": "conn_123"}'` +Output the raw request body of a request by ID. -**Operations Available:** -- `list` - List bulk operations -- `create` - Create new bulk operation -- `plan` - Dry run to see what would be affected -- `get` - Get operation details -- `cancel` - Cancel running operation +**Usage:** -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +```bash +hookdeck gateway request raw-body +``` -Bulk operations allow you to perform actions on multiple resources at once. +**Examples:** -### Event Bulk Retry ```bash -# List bulk event retry operations -hookdeck bulk event-retry list --limit 50 +hookdeck gateway request raw-body req_abc123 +``` + +## Attempts -# Create bulk event retry operation -hookdeck bulk event-retry create --query '{"status": "FAILED", "webhook_id": "conn_123"}' + +- [hookdeck gateway attempt list](#hookdeck-gateway-attempt-list) +- [hookdeck gateway attempt get](#hookdeck-gateway-attempt-get) -# Plan bulk event retry (dry run) -hookdeck bulk event-retry plan --query '{"status": "FAILED"}' +### hookdeck gateway attempt list -# Get bulk operation details -hookdeck bulk event-retry get +List attempts for an event. Requires `--event-id`. -# Cancel bulk operation -hookdeck bulk event-retry cancel -``` +**Usage:** -### Request Bulk Retry ```bash -# List bulk request retry operations -hookdeck bulk request-retry list --limit 50 +hookdeck gateway attempt list [flags] +``` -# Create bulk request retry operation -hookdeck bulk request-retry create --query '{"verified": false, "source_id": "src_123"}' +**Flags:** -# Plan bulk request retry (dry run) -hookdeck bulk request-retry plan --query '{"verified": false}' +| Flag | Type | Description | +|------|------|-------------| +| `--dir` | `string` | Sort direction (asc, desc) | +| `--event-id` | `string` | Filter by event ID (required) | +| `--limit` | `int` | Limit number of results (default "100") | +| `--next` | `string` | Pagination cursor for next page | +| `--order-by` | `string` | Sort key | +| `--output` | `string` | Output format (json) | +| `--prev` | `string` | Pagination cursor for previous page | -# Get bulk operation details -hookdeck bulk request-retry get +**Examples:** -# Cancel bulk operation -hookdeck bulk request-retry cancel +```bash +hookdeck gateway attempt list --event-id evt_abc123 ``` +### hookdeck gateway attempt get + +Get detailed information about an attempt by ID. + +**Usage:** -### Ignored Events Bulk Retry ```bash -# List bulk ignored event retry operations -hookdeck bulk ignored-event-retry list --limit 50 +hookdeck gateway attempt get [flags] +``` -# Create bulk ignored event retry operation -hookdeck bulk ignored-event-retry create --query '{"webhook_id": "conn_123"}' +**Flags:** -# Plan bulk ignored event retry (dry run) -hookdeck bulk ignored-event-retry plan --query '{"webhook_id": "conn_123"}' +| Flag | Type | Description | +|------|------|-------------| +| `--output` | `string` | Output format (json) | -# Get bulk operation details -hookdeck bulk ignored-event-retry get +**Examples:** -# Cancel bulk operation -hookdeck bulk ignored-event-retry cancel +```bash +hookdeck gateway attempt get atm_abc123 ``` + +## Utilities -## Notifications + +- [hookdeck completion](#hookdeck-completion) +- [hookdeck ci](#hookdeck-ci) -🚧 **PLANNED FUNCTIONALITY** - Not yet implemented +### hookdeck completion -### Send webhook notification -```bash -# Send webhook notification -hookdeck notification webhook --url "https://example.com/webhook" \ - --payload '{"message": "Test notification", "timestamp": "2023-12-01T10:00:00Z"}' -``` +Generate bash and zsh completion scripts ---- +**Usage:** -## Command Parameter Patterns +```bash +hookdeck completion [flags] +``` -### Type-Driven Validation -Many commands use type-driven validation where the `--type` parameter determines which additional flags are required or valid: +**Flags:** -- **Source creation**: `--type STRIPE` requires `--webhook-secret`, while `--type GITLAB` requires `--api-key` -- **Issue trigger creation**: `--type delivery` requires `--strategy` and `--connections`, while `--type transformation` requires `--log-level` and `--transformations` +| Flag | Type | Description | +|------|------|-------------| +| `--shell` | `string` | The shell to generate completion commands for. Supports "bash" or "zsh" | +### hookdeck ci -### Collision Resolution -The `hookdeck connection create` command uses prefixed flags to avoid parameter collision when creating inline resources: +Login to your Hookdeck project to forward events in CI -- **Individual resource commands**: Use `--type` (clear context) -- **Connection creation with inline resources**: Use `--source-type` and `--destination-type` (disambiguation) +**Usage:** -### Parameter Conversion Patterns -- **Nested JSON β†’ Flat flags**: `{"configs": {"strategy": "final_attempt"}}` becomes `--strategy final_attempt` -- **Arrays β†’ Comma-separated**: `{"connections": ["conn1", "conn2"]}` becomes `--connections "conn1,conn2"` -- **Boolean presence β†’ Presence flags**: `{"channels": {"email": {}}}` becomes `--email` -- **Complex objects β†’ Value flags**: `{"channels": {"slack": {"channel_name": "#alerts"}}}` becomes `--slack-channel "#alerts"` +```bash +hookdeck ci [flags] +``` -### Global Conventions -- **Resource IDs**: Use `` format in documentation -- **Optional parameters**: Enclosed in square brackets `[--optional-flag]` -- **Required vs optional**: Indicated by command syntax and parameter descriptions -- **Filtering**: Most list commands support filtering by name patterns, IDs, and status -- **Pagination**: All list commands support `--limit` for result limiting -- **Force operations**: Destructive operations support `--force` to skip confirmations +**Flags:** -This comprehensive reference provides complete coverage of all Hookdeck CLI commands, including current functionality and planned features with their full parameter specifications. \ No newline at end of file +| Flag | Type | Description | +|------|------|-------------| +| `--name` | `string` | Your CI name (ex: $GITHUB_REF) | + \ No newline at end of file diff --git a/REFERENCE.template.md b/REFERENCE.template.md new file mode 100644 index 0000000..279abcd --- /dev/null +++ b/REFERENCE.template.md @@ -0,0 +1,77 @@ +# Hookdeck CLI Reference + + + +The Hookdeck CLI provides comprehensive webhook infrastructure management including authentication, project management, resource management, event and attempt querying, and local development tools. This reference covers all available commands and their usage. + +## Table of Contents + + + + +## Global Options + +All commands support these global options: + + + + +## Authentication + + + + +## Projects + + + + +## Local Development + + + + +## Gateway + + + + +## Connections + + + + +## Sources + + + + +## Destinations + + + + +## Transformations + + + + +## Events + + + + +## Requests + + + + +## Attempts + + + + +## Utilities + + + diff --git a/package.json b/package.json index 1031dee..09af5db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hookdeck-cli", - "version": "1.7.1", + "version": "1.8.0-beta.3", "description": "Hookdeck CLI", "repository": { "type": "git", diff --git a/pkg/cmd/attempt.go b/pkg/cmd/attempt.go new file mode 100644 index 0000000..c9f2339 --- /dev/null +++ b/pkg/cmd/attempt.go @@ -0,0 +1,32 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type attemptCmd struct { + cmd *cobra.Command +} + +func newAttemptCmd() *attemptCmd { + ac := &attemptCmd{} + + ac.cmd = &cobra.Command{ + Use: "attempt", + Aliases: []string{"attempts"}, + Args: validators.NoArgs, + Short: "Inspect delivery attempts", + Long: `List or get attempts (single delivery tries for an event). Use --event-id to list attempts for an event.`, + } + + ac.cmd.AddCommand(newAttemptListCmd().cmd) + ac.cmd.AddCommand(newAttemptGetCmd().cmd) + + return ac +} + +func addAttemptCmdTo(parent *cobra.Command) { + parent.AddCommand(newAttemptCmd().cmd) +} diff --git a/pkg/cmd/attempt_get.go b/pkg/cmd/attempt_get.go new file mode 100644 index 0000000..e87b557 --- /dev/null +++ b/pkg/cmd/attempt_get.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type attemptGetCmd struct { + cmd *cobra.Command + output string +} + +func newAttemptGetCmd() *attemptGetCmd { + ac := &attemptGetCmd{} + + ac.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceAttempt), + Long: `Get detailed information about an attempt by ID. + +Examples: + hookdeck gateway attempt get atm_abc123`, + RunE: ac.runAttemptGetCmd, + } + + ac.cmd.Flags().StringVar(&ac.output, "output", "", "Output format (json)") + + return ac +} + +func (ac *attemptGetCmd) runAttemptGetCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + attemptID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + attempt, err := client.GetAttempt(ctx, attemptID) + if err != nil { + return fmt.Errorf("failed to get attempt: %w", err) + } + + if ac.output == "json" { + jsonBytes, err := json.MarshalIndent(attempt, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal attempt to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(attempt.ID)) + fmt.Printf(" Event ID: %s\n", attempt.EventID) + fmt.Printf(" Destination ID: %s\n", attempt.DestinationID) + fmt.Printf(" Attempt #: %d\n", attempt.AttemptNumber) + fmt.Printf(" Status: %s\n", attempt.Status) + fmt.Printf(" Trigger: %s\n", attempt.Trigger) + if attempt.ResponseStatus != nil { + fmt.Printf(" Response: %d\n", *attempt.ResponseStatus) + } + if attempt.ErrorCode != nil { + fmt.Printf(" Error code: %s\n", *attempt.ErrorCode) + } + fmt.Printf(" Method: %s\n", attempt.HTTPMethod) + fmt.Printf(" URL: %s\n", attempt.RequestedURL) + fmt.Println() + return nil +} diff --git a/pkg/cmd/attempt_list.go b/pkg/cmd/attempt_list.go new file mode 100644 index 0000000..514922a --- /dev/null +++ b/pkg/cmd/attempt_list.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type attemptListCmd struct { + cmd *cobra.Command + eventID string + orderBy string + dir string + limit int + next string + prev string + output string +} + +func newAttemptListCmd() *attemptListCmd { + ac := &attemptListCmd{} + + ac.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceAttempt), + Long: `List attempts for an event. Requires --event-id. + +Examples: + hookdeck gateway attempt list --event-id evt_abc123`, + RunE: ac.runAttemptListCmd, + } + + ac.cmd.Flags().StringVar(&ac.eventID, "event-id", "", "Filter by event ID (required)") + ac.cmd.Flags().StringVar(&ac.orderBy, "order-by", "", "Sort key") + ac.cmd.Flags().StringVar(&ac.dir, "dir", "", "Sort direction (asc, desc)") + ac.cmd.Flags().IntVar(&ac.limit, "limit", 100, "Limit number of results") + ac.cmd.Flags().StringVar(&ac.next, "next", "", "Pagination cursor for next page") + ac.cmd.Flags().StringVar(&ac.prev, "prev", "", "Pagination cursor for previous page") + ac.cmd.Flags().StringVar(&ac.output, "output", "", "Output format (json)") + + return ac +} + +func (ac *attemptListCmd) runAttemptListCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if ac.eventID == "" { + return fmt.Errorf("--event-id is required") + } + + client := Config.GetAPIClient() + params := map[string]string{ + "event_id": ac.eventID, + "limit": strconv.Itoa(ac.limit), + } + if ac.orderBy != "" { + params["order_by"] = ac.orderBy + } + if ac.dir != "" { + params["dir"] = ac.dir + } + if ac.next != "" { + params["next"] = ac.next + } + if ac.prev != "" { + params["prev"] = ac.prev + } + + resp, err := client.ListAttempts(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list attempts: %w", err) + } + + if ac.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal attempts to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No attempts found.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, a := range resp.Models { + status := "" + if a.ResponseStatus != nil { + status = fmt.Sprintf(" %d", *a.ResponseStatus) + } + fmt.Printf("%s #%d%s %s\n", color.Green(a.ID), a.AttemptNumber, status, a.Status) + } + return nil +} diff --git a/pkg/cmd/ci.go b/pkg/cmd/ci.go index 6bc0bb9..7806525 100644 --- a/pkg/cmd/ci.go +++ b/pkg/cmd/ci.go @@ -23,11 +23,32 @@ func newCICmd() *ciCmd { Use: "ci", Args: validators.NoArgs, Short: "Login to your Hookdeck project in CI", - Long: `Login to your Hookdeck project to forward events in CI`, - RunE: lc.runCICmd, + Long: `If you want to use Hookdeck in CI for tests or any other purposes, you can use your HOOKDECK_API_KEY to authenticate and start forwarding events.`, + Example: `$ hookdeck ci --api-key $HOOKDECK_API_KEY +Done! The Hookdeck CLI is configured in project MyProject + +$ hookdeck listen 3000 shopify orders + +●── HOOKDECK CLI ──● + +Listening on 1 source β€’ 1 connection β€’ [i] Collapse + +Shopify Source +β”‚ Requests to β†’ https://hkdk.events/src_DAjaFWyyZXsFdZrTOKpuHnOH +└─ Forwards to β†’ http://localhost:3000/webhooks/shopify/orders (Orders Service) + +πŸ’‘ View dashboard to inspect, retry & bookmark events: https://dashboard.hookdeck.com/events/cli?team_id=... + +Events β€’ [↑↓] Navigate ────────────────────────────────────────────────────────── + +> 2025-10-12 14:42:55 [200] POST http://localhost:3000/webhooks/shopify/orders (34ms) β†’ https://dashboard.hookdeck.com/events/evt_... + +─────────────────────────────────────────────────────────────────────────────── +> βœ“ Last event succeeded with status 200 | [r] Retry β€’ [o] Open in dashboard β€’ [d] Show data`, + RunE: lc.runCICmd, } - lc.cmd.Flags().StringVar(&lc.apiKey, "api-key", os.Getenv("HOOKDECK_API_KEY"), "Your API key to use for the command") - lc.cmd.Flags().StringVar(&lc.name, "name", "", "Your CI name (ex: $GITHUB_REF)") + lc.cmd.Flags().StringVar(&lc.apiKey, "api-key", os.Getenv("HOOKDECK_API_KEY"), "Your Hookdeck Project API key. The CLI reads from HOOKDECK_API_KEY if not provided.") + lc.cmd.Flags().StringVar(&lc.name, "name", "", "Name of the CI run (ex: GITHUB_REF) for identification in the dashboard") return lc } diff --git a/pkg/cmd/completion.go b/pkg/cmd/completion.go index cc6408f..d17293d 100644 --- a/pkg/cmd/completion.go +++ b/pkg/cmd/completion.go @@ -24,7 +24,10 @@ func newCompletionCmd() *completionCmd { cc.cmd = &cobra.Command{ Use: "completion", Short: "Generate bash and zsh completion scripts", + Long: "Generate bash and zsh completion scripts. This command runs on install when using Homebrew or Scoop. You can optionally run it when using binaries directly or without a package manager.", Args: validators.NoArgs, + Example: ` $ hookdeck completion --shell zsh + $ hookdeck completion --shell bash`, RunE: func(cmd *cobra.Command, args []string) error { return selectShell(cc.shell) }, diff --git a/pkg/cmd/connection.go b/pkg/cmd/connection.go index 7252589..7ba111c 100644 --- a/pkg/cmd/connection.go +++ b/pkg/cmd/connection.go @@ -1,11 +1,16 @@ package cmd import ( + "fmt" + "os" + "github.com/spf13/cobra" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) +const connectionDeprecationNotice = "Deprecation notice: 'hookdeck connection' and 'hookdeck connections' are deprecated. In a future version please use 'hookdeck gateway connection'.\n" + type connectionCmd struct { cmd *cobra.Command } @@ -26,10 +31,16 @@ existing resources. [BETA] This feature is in beta. Please share bugs and feedback via: https://github.com/hookdeck/hookdeck-cli/issues`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if shouldShowConnectionDeprecation() { + fmt.Fprint(os.Stderr, connectionDeprecationNotice) + } + }, } cc.cmd.AddCommand(newConnectionCreateCmd().cmd) cc.cmd.AddCommand(newConnectionUpsertCmd().cmd) + cc.cmd.AddCommand(newConnectionUpdateCmd().cmd) cc.cmd.AddCommand(newConnectionListCmd().cmd) cc.cmd.AddCommand(newConnectionGetCmd().cmd) cc.cmd.AddCommand(newConnectionDeleteCmd().cmd) diff --git a/pkg/cmd/connection_common.go b/pkg/cmd/connection_common.go new file mode 100644 index 0000000..b71fbf9 --- /dev/null +++ b/pkg/cmd/connection_common.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" +) + +// shouldShowConnectionDeprecation returns true when the user invoked the +// root-level alias (hookdeck connection / hookdeck connections) and we +// should print a deprecation notice. Returns false when: +// - Invoked under gateway (hookdeck gateway connection ...) +// - Output is JSON (--output json or --output=json), so the notice would pollute machine output +// - Any future silent/quiet flag is set (none today; add here when introduced) +func shouldShowConnectionDeprecation() bool { + args := os.Args + if len(args) < 2 { + return false + } + first := args[1] + if first != "connection" && first != "connections" { + return false // under gateway or another command + } + for i, a := range args { + if a == "--output" && i+1 < len(args) && strings.TrimSpace(args[i+1]) == "json" { + return false + } + if strings.HasPrefix(a, "--output=") && strings.TrimSpace(strings.TrimPrefix(a, "--output=")) == "json" { + return false + } + // If a global silent/quiet flag is added later, check for it here and return false + } + return true +} + +// connectionRuleFlags holds rule-related flags shared by connection create, update, and upsert. +// Used to avoid duplicating flag definitions and rule-building logic. +type connectionRuleFlags struct { + Rules string + RulesFile string + + RuleRetryStrategy string + RuleRetryCount int + RuleRetryInterval int + RuleRetryResponseStatusCode string + + RuleFilterBody string + RuleFilterHeaders string + RuleFilterQuery string + RuleFilterPath string + + RuleTransformName string + RuleTransformCode string + RuleTransformEnv string + + RuleDelay int + + RuleDeduplicateWindow int + RuleDeduplicateIncludeFields string + RuleDeduplicateExcludeFields string +} + +// addConnectionRuleFlags binds rule flags to cmd. Pass a pointer to the flags struct +// (e.g. embedded in connectionCreateCmd, connectionUpdateCmd) so values are populated. +func addConnectionRuleFlags(cmd *cobra.Command, f *connectionRuleFlags) { + cmd.Flags().StringVar(&f.Rules, "rules", "", "JSON string representing the entire rules array") + cmd.Flags().StringVar(&f.RulesFile, "rules-file", "", "Path to a JSON file containing the rules array") + + cmd.Flags().StringVar(&f.RuleRetryStrategy, "rule-retry-strategy", "", "Retry strategy (linear, exponential)") + cmd.Flags().IntVar(&f.RuleRetryCount, "rule-retry-count", 0, "Number of retry attempts") + cmd.Flags().IntVar(&f.RuleRetryInterval, "rule-retry-interval", 0, "Interval between retries in milliseconds") + cmd.Flags().StringVar(&f.RuleRetryResponseStatusCode, "rule-retry-response-status-codes", "", "Comma-separated HTTP status codes to retry on") + + cmd.Flags().StringVar(&f.RuleFilterBody, "rule-filter-body", "", "JQ expression to filter on request body") + cmd.Flags().StringVar(&f.RuleFilterHeaders, "rule-filter-headers", "", "JQ expression to filter on request headers") + cmd.Flags().StringVar(&f.RuleFilterQuery, "rule-filter-query", "", "JQ expression to filter on request query parameters") + cmd.Flags().StringVar(&f.RuleFilterPath, "rule-filter-path", "", "JQ expression to filter on request path") + + cmd.Flags().StringVar(&f.RuleTransformName, "rule-transform-name", "", "Name or ID of the transformation to apply") + cmd.Flags().StringVar(&f.RuleTransformCode, "rule-transform-code", "", "Transformation code (if creating inline)") + cmd.Flags().StringVar(&f.RuleTransformEnv, "rule-transform-env", "", "JSON string representing environment variables for transformation") + + cmd.Flags().IntVar(&f.RuleDelay, "rule-delay", 0, "Delay in milliseconds") + + cmd.Flags().IntVar(&f.RuleDeduplicateWindow, "rule-deduplicate-window", 0, "Time window in seconds for deduplication") + cmd.Flags().StringVar(&f.RuleDeduplicateIncludeFields, "rule-deduplicate-include-fields", "", "Comma-separated list of fields to include for deduplication") + cmd.Flags().StringVar(&f.RuleDeduplicateExcludeFields, "rule-deduplicate-exclude-fields", "", "Comma-separated list of fields to exclude for deduplication") +} + +// buildConnectionRules builds a slice of rules from connectionRuleFlags. +// If rulesStr or rulesFile is non-empty, those are parsed as JSON and returned; +// otherwise individual rule flags are assembled into rules. +// Shared by connection update and (for consistency) can be used by create/upsert. +func buildConnectionRules(f *connectionRuleFlags) ([]hookdeck.Rule, error) { + if f.Rules != "" { + var rules []hookdeck.Rule + if err := json.Unmarshal([]byte(f.Rules), &rules); err != nil { + return nil, fmt.Errorf("invalid JSON for --rules: %w", err) + } + return rules, nil + } + + if f.RulesFile != "" { + data, err := os.ReadFile(f.RulesFile) + if err != nil { + return nil, fmt.Errorf("failed to read rules file: %w", err) + } + var rules []hookdeck.Rule + if err := json.Unmarshal(data, &rules); err != nil { + return nil, fmt.Errorf("invalid JSON in rules file: %w", err) + } + return rules, nil + } + + // Build each rule type (order matches create: deduplicate -> transform -> filter -> delay -> retry) + var rules []hookdeck.Rule + + if f.RuleDeduplicateWindow > 0 { + rule := hookdeck.Rule{ + "type": "deduplicate", + "window": f.RuleDeduplicateWindow, + } + if f.RuleDeduplicateIncludeFields != "" { + rule["include_fields"] = strings.Split(f.RuleDeduplicateIncludeFields, ",") + } + if f.RuleDeduplicateExcludeFields != "" { + rule["exclude_fields"] = strings.Split(f.RuleDeduplicateExcludeFields, ",") + } + rules = append(rules, rule) + } + + hasTransform := f.RuleTransformName != "" || f.RuleTransformCode != "" || f.RuleTransformEnv != "" + if hasTransform { + rule := hookdeck.Rule{"type": "transform"} + transformConfig := make(map[string]interface{}) + if f.RuleTransformName != "" { + transformConfig["name"] = f.RuleTransformName + } + if f.RuleTransformCode != "" { + transformConfig["code"] = f.RuleTransformCode + } + if f.RuleTransformEnv != "" { + var env map[string]interface{} + if err := json.Unmarshal([]byte(f.RuleTransformEnv), &env); err != nil { + return nil, fmt.Errorf("invalid JSON for --rule-transform-env: %w", err) + } + transformConfig["env"] = env + } + rule["transformation"] = transformConfig + rules = append(rules, rule) + } + + if f.RuleFilterBody != "" || f.RuleFilterHeaders != "" || f.RuleFilterQuery != "" || f.RuleFilterPath != "" { + rule := hookdeck.Rule{"type": "filter"} + if f.RuleFilterBody != "" { + rule["body"] = f.RuleFilterBody + } + if f.RuleFilterHeaders != "" { + rule["headers"] = f.RuleFilterHeaders + } + if f.RuleFilterQuery != "" { + rule["query"] = f.RuleFilterQuery + } + if f.RuleFilterPath != "" { + rule["path"] = f.RuleFilterPath + } + rules = append(rules, rule) + } + + if f.RuleDelay > 0 { + rules = append(rules, hookdeck.Rule{ + "type": "delay", + "delay": f.RuleDelay, + }) + } + + if f.RuleRetryStrategy != "" { + rule := hookdeck.Rule{ + "type": "retry", + "strategy": f.RuleRetryStrategy, + } + if f.RuleRetryCount > 0 { + rule["count"] = f.RuleRetryCount + } + if f.RuleRetryInterval > 0 { + rule["interval"] = f.RuleRetryInterval + } + if f.RuleRetryResponseStatusCode != "" { + rule["response_status_codes"] = f.RuleRetryResponseStatusCode + } + rules = append(rules, rule) + } + + return rules, nil +} diff --git a/pkg/cmd/connection_common_test.go b/pkg/cmd/connection_common_test.go new file mode 100644 index 0000000..21727b2 --- /dev/null +++ b/pkg/cmd/connection_common_test.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestShouldShowConnectionDeprecation(t *testing.T) { + saveArgs := os.Args + defer func() { os.Args = saveArgs }() + + tests := []struct { + name string + args []string + showWant bool + }{ + {"root connection list", []string{"hookdeck", "connection", "list"}, true}, + {"root connections list", []string{"hookdeck", "connections", "list"}, true}, + {"gateway path - no notice", []string{"hookdeck", "gateway", "connection", "list"}, false}, + {"gateway path connections - no notice", []string{"hookdeck", "gateway", "connections", "list"}, false}, + {"output json - no notice", []string{"hookdeck", "connection", "list", "--output", "json"}, false}, + {"output=json - no notice", []string{"hookdeck", "connection", "list", "--output=json"}, false}, + {"single arg - no notice", []string{"hookdeck"}, false}, + {"connection only - show", []string{"hookdeck", "connection"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Args = tt.args + got := shouldShowConnectionDeprecation() + assert.Equal(t, tt.showWant, got, "args=%v", tt.args) + }) + } +} diff --git a/pkg/cmd/connection_create.go b/pkg/cmd/connection_create.go index 86f8901..d02a6df 100644 --- a/pkg/cmd/connection_create.go +++ b/pkg/cmd/connection_create.go @@ -9,7 +9,6 @@ import ( "github.com/spf13/cobra" - "github.com/hookdeck/hookdeck-cli/pkg/cmd/sources" "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" "github.com/hookdeck/hookdeck-cli/pkg/validators" ) @@ -92,34 +91,8 @@ type connectionCreateCmd struct { DestinationRateLimit int DestinationRateLimitPeriod string - // Rule flags - Retry - RuleRetryStrategy string - RuleRetryCount int - RuleRetryInterval int - RuleRetryResponseStatusCode string - - // Rule flags - Filter - RuleFilterBody string - RuleFilterHeaders string - RuleFilterQuery string - RuleFilterPath string - - // Rule flags - Transform - RuleTransformName string - RuleTransformCode string - RuleTransformEnv string - - // Rule flags - Delay - RuleDelay int - - // Rule flags - Deduplicate - RuleDeduplicateWindow int - RuleDeduplicateIncludeFields string - RuleDeduplicateExcludeFields string - - // Rules JSON fallback - Rules string - RulesFile string + // Rule flags shared with update/upsert + connectionRuleFlags // Reference existing resources sourceID string @@ -132,7 +105,7 @@ func newConnectionCreateCmd() *connectionCreateCmd { cc.cmd = &cobra.Command{ Use: "create", Args: validators.NoArgs, - Short: "Create a new connection", + Short: ShortCreate(ResourceConnection), Long: `Create a connection between a source and destination. You can either reference existing resources by ID or create them inline. @@ -253,34 +226,7 @@ func newConnectionCreateCmd() *connectionCreateCmd { cc.cmd.Flags().IntVar(&cc.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") cc.cmd.Flags().StringVar(&cc.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") - // Rule flags - Retry - cc.cmd.Flags().StringVar(&cc.RuleRetryStrategy, "rule-retry-strategy", "", "Retry strategy (linear, exponential)") - cc.cmd.Flags().IntVar(&cc.RuleRetryCount, "rule-retry-count", 0, "Number of retry attempts") - cc.cmd.Flags().IntVar(&cc.RuleRetryInterval, "rule-retry-interval", 0, "Interval between retries in milliseconds") - cc.cmd.Flags().StringVar(&cc.RuleRetryResponseStatusCode, "rule-retry-response-status-codes", "", "Comma-separated HTTP status codes to retry on (e.g., '429,500,502')") - - // Rule flags - Filter - cc.cmd.Flags().StringVar(&cc.RuleFilterBody, "rule-filter-body", "", "JQ expression to filter on request body") - cc.cmd.Flags().StringVar(&cc.RuleFilterHeaders, "rule-filter-headers", "", "JQ expression to filter on request headers") - cc.cmd.Flags().StringVar(&cc.RuleFilterQuery, "rule-filter-query", "", "JQ expression to filter on request query parameters") - cc.cmd.Flags().StringVar(&cc.RuleFilterPath, "rule-filter-path", "", "JQ expression to filter on request path") - - // Rule flags - Transform - cc.cmd.Flags().StringVar(&cc.RuleTransformName, "rule-transform-name", "", "Name or ID of the transformation to apply") - cc.cmd.Flags().StringVar(&cc.RuleTransformCode, "rule-transform-code", "", "Transformation code (if creating inline)") - cc.cmd.Flags().StringVar(&cc.RuleTransformEnv, "rule-transform-env", "", "JSON string representing environment variables for transformation") - - // Rule flags - Delay - cc.cmd.Flags().IntVar(&cc.RuleDelay, "rule-delay", 0, "Delay in milliseconds") - - // Rule flags - Deduplicate - cc.cmd.Flags().IntVar(&cc.RuleDeduplicateWindow, "rule-deduplicate-window", 0, "Time window in seconds for deduplication") - cc.cmd.Flags().StringVar(&cc.RuleDeduplicateIncludeFields, "rule-deduplicate-include-fields", "", "Comma-separated list of fields to include for deduplication") - cc.cmd.Flags().StringVar(&cc.RuleDeduplicateExcludeFields, "rule-deduplicate-exclude-fields", "", "Comma-separated list of fields to exclude for deduplication") - - // Rules JSON fallback - cc.cmd.Flags().StringVar(&cc.Rules, "rules", "", "JSON string representing the entire rules array") - cc.cmd.Flags().StringVar(&cc.RulesFile, "rules-file", "", "Path to a JSON file containing the rules array") + addConnectionRuleFlags(cc.cmd, &cc.connectionRuleFlags) // Reference existing resources cc.cmd.Flags().StringVar(&cc.sourceID, "source-id", "", "Use existing source by ID") @@ -339,38 +285,17 @@ func (cc *connectionCreateCmd) validateFlags(cmd *cobra.Command, args []string) } } - // Validate source authentication flags based on source type - if hasInlineSource && cc.SourceConfig == "" && cc.SourceConfigFile == "" { - sourceTypes, err := sources.FetchSourceTypes() - if err != nil { - // We can't validate, so we'll just warn and let the API handle it - fmt.Printf("Warning: could not fetch source types for validation: %v\n", err) - return nil - } - - sourceType, ok := sourceTypes[strings.ToUpper(cc.sourceType)] - if !ok { - // This is an unknown source type, let the API validate it - return nil - } - - switch sourceType.AuthScheme { - case "webhook_secret": - if cc.SourceWebhookSecret == "" { - return fmt.Errorf("error: --source-webhook-secret is required for source type %s", cc.sourceType) - } - case "api_key": - if cc.SourceAPIKey == "" { - return fmt.Errorf("error: --source-api-key is required for source type %s", cc.sourceType) - } - case "basic_auth": - if cc.SourceBasicAuthUser == "" || cc.SourceBasicAuthPass == "" { - return fmt.Errorf("error: --source-basic-auth-user and --source-basic-auth-pass are required for source type %s", cc.sourceType) - } - case "hmac": - if cc.SourceHMACSecret == "" { - return fmt.Errorf("error: --source-hmac-secret is required for source type %s", cc.sourceType) - } + // Validate source authentication flags based on source type (cached OpenAPI spec) + if hasInlineSource { + auth := sourceAuthFlags{ + WebhookSecret: cc.SourceWebhookSecret, + APIKey: cc.SourceAPIKey, + BasicAuthUser: cc.SourceBasicAuthUser, + BasicAuthPass: cc.SourceBasicAuthPass, + HMACSecret: cc.SourceHMACSecret, + } + if err := validateSourceAuthFromSpec(cc.sourceType, cc.SourceConfig != "" || cc.SourceConfigFile != "", auth, "source-"); err != nil { + return err } } @@ -511,7 +436,7 @@ func (cc *connectionCreateCmd) runConnectionCreateCmd(cmd *cobra.Command, args [ } // Handle Rules - rules, err := cc.buildRulesArray(cmd) + rules, err := buildConnectionRules(&cc.connectionRuleFlags) if err != nil { return err } @@ -825,12 +750,14 @@ func (cc *connectionCreateCmd) buildAuthConfig() (map[string]interface{}, error) } func (cc *connectionCreateCmd) buildSourceConfig() (map[string]interface{}, error) { - // Handle JSON config first, as it overrides individual flags + // Handle JSON config first (same precedence as source commands) if cc.SourceConfig != "" { var config map[string]interface{} if err := json.Unmarshal([]byte(cc.SourceConfig), &config); err != nil { return nil, fmt.Errorf("invalid JSON in --source-config: %w", err) } + normalizeSourceConfigAuth(config, cc.sourceType) + ensureSourceConfigAuthTypeForHTTP(config, cc.sourceType) return config, nil } if cc.SourceConfigFile != "" { @@ -842,221 +769,35 @@ func (cc *connectionCreateCmd) buildSourceConfig() (map[string]interface{}, erro if err := json.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("invalid JSON in --source-config-file: %w", err) } + normalizeSourceConfigAuth(config, cc.sourceType) + ensureSourceConfigAuthTypeForHTTP(config, cc.sourceType) return config, nil } - - // Build config from individual flags - config := make(map[string]interface{}) - if cc.SourceWebhookSecret != "" { - config["webhook_secret"] = cc.SourceWebhookSecret - } - if cc.SourceAPIKey != "" { - config["api_key"] = cc.SourceAPIKey - } - if cc.SourceBasicAuthUser != "" || cc.SourceBasicAuthPass != "" { - config["basic_auth"] = map[string]string{ - "username": cc.SourceBasicAuthUser, - "password": cc.SourceBasicAuthPass, - } - } - if cc.SourceHMACSecret != "" { - hmacConfig := map[string]string{"secret": cc.SourceHMACSecret} - if cc.SourceHMACAlgo != "" { - hmacConfig["algorithm"] = cc.SourceHMACAlgo - } - config["hmac"] = hmacConfig - } - - // Add allowed HTTP methods - if cc.SourceAllowedHTTPMethods != "" { - methods := strings.Split(cc.SourceAllowedHTTPMethods, ",") - // Trim whitespace and validate - validMethods := []string{} - allowedMethods := map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true} - for _, method := range methods { - method = strings.TrimSpace(strings.ToUpper(method)) - if !allowedMethods[method] { - return nil, fmt.Errorf("invalid HTTP method '%s' in --source-allowed-http-methods (allowed: GET, POST, PUT, PATCH, DELETE)", method) - } - validMethods = append(validMethods, method) - } - config["allowed_http_methods"] = validMethods + // Build from individual --source-* flags using shared logic + f := &sourceConfigFlags{ + WebhookSecret: cc.SourceWebhookSecret, + APIKey: cc.SourceAPIKey, + BasicAuthUser: cc.SourceBasicAuthUser, + BasicAuthPass: cc.SourceBasicAuthPass, + HMACSecret: cc.SourceHMACSecret, + HMACAlgo: cc.SourceHMACAlgo, + AllowedHTTPMethods: cc.SourceAllowedHTTPMethods, + CustomResponseBody: cc.SourceCustomResponseBody, + CustomResponseType: cc.SourceCustomResponseType, + } + config, err := buildSourceConfigFromIndividualFlags(f, "source-", cc.sourceType) + if err != nil { + return nil, err } - - // Add custom response configuration - if cc.SourceCustomResponseType != "" || cc.SourceCustomResponseBody != "" { - if cc.SourceCustomResponseType == "" { - return nil, fmt.Errorf("--source-custom-response-content-type is required when using --source-custom-response-body") - } - if cc.SourceCustomResponseBody == "" { - return nil, fmt.Errorf("--source-custom-response-body is required when using --source-custom-response-content-type") - } - - // Validate content type - validContentTypes := map[string]bool{"json": true, "text": true, "xml": true} - contentType := strings.ToLower(cc.SourceCustomResponseType) - if !validContentTypes[contentType] { - return nil, fmt.Errorf("invalid content type '%s' in --source-custom-response-content-type (allowed: json, text, xml)", cc.SourceCustomResponseType) - } - - // Validate body length (max 1000 chars per API spec) - if len(cc.SourceCustomResponseBody) > 1000 { - return nil, fmt.Errorf("--source-custom-response-body exceeds maximum length of 1000 characters (got %d)", len(cc.SourceCustomResponseBody)) - } - - config["custom_response"] = map[string]interface{}{ - "content_type": contentType, - "body": cc.SourceCustomResponseBody, - } + if config != nil { + ensureSourceConfigAuthTypeForHTTP(config, cc.sourceType) } - if len(config) == 0 { return make(map[string]interface{}), nil } - return config, nil } -// buildRulesArray constructs the rules array from flags in logical execution order -// Order: filter -> transform -> deduplicate -> delay -> retry -// Note: This is the default order for individual flags. For custom order, use --rules or --rules-file -func (cc *connectionCreateCmd) buildRulesArray(cmd *cobra.Command) ([]hookdeck.Rule, error) { - // Handle JSON fallback first - if cc.Rules != "" { - var rules []hookdeck.Rule - if err := json.Unmarshal([]byte(cc.Rules), &rules); err != nil { - return nil, fmt.Errorf("invalid JSON in --rules: %w", err) - } - return rules, nil - } - if cc.RulesFile != "" { - data, err := os.ReadFile(cc.RulesFile) - if err != nil { - return nil, fmt.Errorf("could not read --rules-file: %w", err) - } - var rules []hookdeck.Rule - if err := json.Unmarshal(data, &rules); err != nil { - return nil, fmt.Errorf("invalid JSON in --rules-file: %w", err) - } - return rules, nil - } - - // Track which rule types have been encountered - ruleMap := make(map[string]hookdeck.Rule) - - // Determine which rule types are present by checking flags - // Note: We don't track order from flags because pflag.Visit() processes flags alphabetically - hasRetryFlags := cc.RuleRetryStrategy != "" || cc.RuleRetryCount > 0 || cc.RuleRetryInterval > 0 || cc.RuleRetryResponseStatusCode != "" - hasFilterFlags := cc.RuleFilterBody != "" || cc.RuleFilterHeaders != "" || cc.RuleFilterQuery != "" || cc.RuleFilterPath != "" - hasTransformFlags := cc.RuleTransformName != "" || cc.RuleTransformCode != "" || cc.RuleTransformEnv != "" - hasDelayFlags := cc.RuleDelay > 0 - hasDeduplicateFlags := cc.RuleDeduplicateWindow > 0 || cc.RuleDeduplicateIncludeFields != "" || cc.RuleDeduplicateExcludeFields != "" - - // Initialize rule entries for each type that has flags set - if hasRetryFlags { - ruleMap["retry"] = make(hookdeck.Rule) - } - if hasFilterFlags { - ruleMap["filter"] = make(hookdeck.Rule) - } - if hasTransformFlags { - ruleMap["transform"] = make(hookdeck.Rule) - } - if hasDelayFlags { - ruleMap["delay"] = make(hookdeck.Rule) - } - if hasDeduplicateFlags { - ruleMap["deduplicate"] = make(hookdeck.Rule) - } - - // Build each rule based on the flags set - if rule, ok := ruleMap["retry"]; ok { - rule["type"] = "retry" - if cc.RuleRetryStrategy != "" { - rule["strategy"] = cc.RuleRetryStrategy - } - if cc.RuleRetryCount > 0 { - rule["count"] = cc.RuleRetryCount - } - if cc.RuleRetryInterval > 0 { - rule["interval"] = cc.RuleRetryInterval - } - if cc.RuleRetryResponseStatusCode != "" { - rule["response_status_codes"] = cc.RuleRetryResponseStatusCode - } - } - - if rule, ok := ruleMap["filter"]; ok { - rule["type"] = "filter" - if cc.RuleFilterBody != "" { - rule["body"] = cc.RuleFilterBody - } - if cc.RuleFilterHeaders != "" { - rule["headers"] = cc.RuleFilterHeaders - } - if cc.RuleFilterQuery != "" { - rule["query"] = cc.RuleFilterQuery - } - if cc.RuleFilterPath != "" { - rule["path"] = cc.RuleFilterPath - } - } - - if rule, ok := ruleMap["transform"]; ok { - rule["type"] = "transform" - transformConfig := make(map[string]interface{}) - if cc.RuleTransformName != "" { - transformConfig["name"] = cc.RuleTransformName - } - if cc.RuleTransformCode != "" { - transformConfig["code"] = cc.RuleTransformCode - } - if cc.RuleTransformEnv != "" { - var env map[string]interface{} - if err := json.Unmarshal([]byte(cc.RuleTransformEnv), &env); err != nil { - return nil, fmt.Errorf("invalid JSON in --rule-transform-env: %w", err) - } - transformConfig["env"] = env - } - rule["transformation"] = transformConfig - } - - if rule, ok := ruleMap["delay"]; ok { - rule["type"] = "delay" - if cc.RuleDelay > 0 { - rule["delay"] = cc.RuleDelay - } - } - - if rule, ok := ruleMap["deduplicate"]; ok { - rule["type"] = "deduplicate" - if cc.RuleDeduplicateWindow > 0 { - rule["window"] = cc.RuleDeduplicateWindow - } - if cc.RuleDeduplicateIncludeFields != "" { - fields := strings.Split(cc.RuleDeduplicateIncludeFields, ",") - rule["include_fields"] = fields - } - if cc.RuleDeduplicateExcludeFields != "" { - fields := strings.Split(cc.RuleDeduplicateExcludeFields, ",") - rule["exclude_fields"] = fields - } - } - - // Build rules array in logical execution order - // Order: deduplicate -> transform -> filter -> delay -> retry - // This order matches the API's default ordering for proper data flow through the pipeline - rules := make([]hookdeck.Rule, 0, len(ruleMap)) - ruleTypes := []string{"deduplicate", "transform", "filter", "delay", "retry"} - for _, ruleType := range ruleTypes { - if rule, ok := ruleMap[ruleType]; ok { - rules = append(rules, rule) - } - } - - return rules, nil -} - // enhanceCreateError adds helpful hints to API errors based on the flags used func (cc *connectionCreateCmd) enhanceCreateError(err error) error { return cc.enhanceConnectionError(err, "create") diff --git a/pkg/cmd/connection_delete.go b/pkg/cmd/connection_delete.go index 4ef253c..a5386e1 100644 --- a/pkg/cmd/connection_delete.go +++ b/pkg/cmd/connection_delete.go @@ -21,8 +21,8 @@ func newConnectionDeleteCmd() *connectionDeleteCmd { cc.cmd = &cobra.Command{ Use: "delete ", Args: validators.ExactArgs(1), - Short: "Delete a connection", - Long: `Delete a connection. + Short: ShortDelete(ResourceConnection), + Long: LongDeleteIntro(ResourceConnection) + ` Examples: # Delete a connection (with confirmation) diff --git a/pkg/cmd/connection_disable.go b/pkg/cmd/connection_disable.go index 477446d..48e30e0 100644 --- a/pkg/cmd/connection_disable.go +++ b/pkg/cmd/connection_disable.go @@ -19,10 +19,8 @@ func newConnectionDisableCmd() *connectionDisableCmd { cc.cmd = &cobra.Command{ Use: "disable ", Args: validators.ExactArgs(1), - Short: "Disable a connection", - Long: `Disable an active connection. - -The connection will stop processing events until re-enabled.`, + Short: ShortDisable(ResourceConnection), + Long: LongDisableIntro(ResourceConnection), RunE: cc.runConnectionDisableCmd, } diff --git a/pkg/cmd/connection_enable.go b/pkg/cmd/connection_enable.go index 5e84a13..1f1ef7a 100644 --- a/pkg/cmd/connection_enable.go +++ b/pkg/cmd/connection_enable.go @@ -19,10 +19,8 @@ func newConnectionEnableCmd() *connectionEnableCmd { cc.cmd = &cobra.Command{ Use: "enable ", Args: validators.ExactArgs(1), - Short: "Enable a connection", - Long: `Enable a disabled connection. - -The connection will resume processing events.`, + Short: ShortEnable(ResourceConnection), + Long: LongEnableIntro(ResourceConnection), RunE: cc.runConnectionEnableCmd, } diff --git a/pkg/cmd/connection_get.go b/pkg/cmd/connection_get.go index c873bc7..d314402 100644 --- a/pkg/cmd/connection_get.go +++ b/pkg/cmd/connection_get.go @@ -17,7 +17,8 @@ import ( type connectionGetCmd struct { cmd *cobra.Command - output string + output string + includeSourceAuth bool includeDestinationAuth bool } @@ -27,10 +28,8 @@ func newConnectionGetCmd() *connectionGetCmd { cc.cmd = &cobra.Command{ Use: "get ", Args: validators.ExactArgs(1), - Short: "Get connection details", - Long: `Get detailed information about a specific connection. - -You can specify either a connection ID or name. + Short: ShortGet(ResourceConnection), + Long: LongGetIntro(ResourceConnection) + ` Examples: # Get connection by ID @@ -42,6 +41,7 @@ Examples: } cc.cmd.Flags().StringVar(&cc.output, "output", "", "Output format (json)") + addIncludeSourceAuthFlagForConnection(cc.cmd, &cc.includeSourceAuth) addIncludeDestinationAuthFlag(cc.cmd, &cc.includeDestinationAuth) return cc @@ -69,9 +69,14 @@ func (cc *connectionGetCmd) runConnectionGetCmd(cmd *cobra.Command, args []strin } // The connections API does not support include=config.auth, so when - // --include-destination-auth is requested we fetch the destination directly - // from GET /destinations/{id}?include=config.auth and merge the enriched - // config back into the connection response. + // --include-source-auth or --include-destination-auth is requested we fetch + // the source or destination directly with ?include=config.auth and merge. + if cc.includeSourceAuth && conn.Source != nil { + src, err := apiClient.GetSource(ctx, conn.Source.ID, includeAuthParams(true)) + if err == nil { + conn.Source = src + } + } if cc.includeDestinationAuth && conn.Destination != nil { dest, err := apiClient.GetDestination(ctx, conn.Destination.ID, includeAuthParams(true)) if err == nil { diff --git a/pkg/cmd/connection_include.go b/pkg/cmd/connection_include.go index 27779bc..1c370eb 100644 --- a/pkg/cmd/connection_include.go +++ b/pkg/cmd/connection_include.go @@ -2,14 +2,33 @@ package cmd import "github.com/spf13/cobra" -// addIncludeDestinationAuthFlag registers the --include-destination-auth flag on a cobra command. -// When set, the CLI fetches destination auth credentials via -// GET /destinations/{id}?include=config.auth and merges them into the response. +// addIncludeAuthFlagForDestination registers the --include-auth flag on a destination get command. +// When set, the CLI requests destination auth via GET /destinations/{id}?include=config.auth. +func addIncludeAuthFlagForDestination(cmd *cobra.Command, target *bool) { + cmd.Flags().BoolVar(target, "include-auth", false, + "Include authentication credentials in the response") +} + +// addIncludeSourceAuthFlagForConnection registers the --include-source-auth flag on a connection get command. +func addIncludeSourceAuthFlagForConnection(cmd *cobra.Command, target *bool) { + cmd.Flags().BoolVar(target, "include-source-auth", false, + "Include source authentication credentials in the response") +} + +// addIncludeDestinationAuthFlag registers the --include-destination-auth flag on a connection get command. +// Use the fully qualified name on connection since connection get can include source or destination auth. func addIncludeDestinationAuthFlag(cmd *cobra.Command, target *bool) { cmd.Flags().BoolVar(target, "include-destination-auth", false, "Include destination authentication credentials in the response") } +// addIncludeSourceAuthFlag registers the --include-auth flag on a cobra command (e.g. source get). +// When set, the CLI requests source auth via GET /sources/{id}?include=config.auth. +func addIncludeSourceAuthFlag(cmd *cobra.Command, target *bool) { + cmd.Flags().BoolVar(target, "include-auth", false, + "Include source authentication credentials in the response") +} + // includeAuthParams returns a map with the include query parameter set // if includeAuth is true, or nil otherwise. func includeAuthParams(includeAuth bool) map[string]string { diff --git a/pkg/cmd/connection_list.go b/pkg/cmd/connection_list.go index 416b22f..dca1ade 100644 --- a/pkg/cmd/connection_list.go +++ b/pkg/cmd/connection_list.go @@ -30,7 +30,7 @@ func newConnectionListCmd() *connectionListCmd { cc.cmd = &cobra.Command{ Use: "list", Args: validators.NoArgs, - Short: "List connections", + Short: ShortList(ResourceConnection), Long: `List all connections or filter by source/destination. Examples: diff --git a/pkg/cmd/connection_source_config_test.go b/pkg/cmd/connection_source_config_test.go index a272056..62532c2 100644 --- a/pkg/cmd/connection_source_config_test.go +++ b/pkg/cmd/connection_source_config_test.go @@ -19,8 +19,13 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - if config["webhook_secret"] != "whsec_test123" { - t.Errorf("expected webhook_secret whsec_test123, got %v", config["webhook_secret"]) + auth, ok := config["auth"].(map[string]interface{}) + if !ok { + t.Errorf("expected auth map, got %T", config["auth"]) + return + } + if auth["webhook_secret_key"] != "whsec_test123" { + t.Errorf("expected auth.webhook_secret_key whsec_test123, got %v", auth["webhook_secret_key"]) } }, }, @@ -31,8 +36,13 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - if config["api_key"] != "sk_test_abc123" { - t.Errorf("expected api_key sk_test_abc123, got %v", config["api_key"]) + auth, ok := config["auth"].(map[string]interface{}) + if !ok { + t.Errorf("expected auth map, got %T", config["auth"]) + return + } + if auth["api_key"] != "sk_test_abc123" { + t.Errorf("expected auth.api_key sk_test_abc123, got %v", auth["api_key"]) } }, }, @@ -44,16 +54,16 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - basicAuth, ok := config["basic_auth"].(map[string]string) + auth, ok := config["auth"].(map[string]interface{}) if !ok { - t.Errorf("expected basic_auth map, got %T", config["basic_auth"]) + t.Errorf("expected auth map, got %T", config["auth"]) return } - if basicAuth["username"] != "testuser" { - t.Errorf("expected username testuser, got %v", basicAuth["username"]) + if auth["username"] != "testuser" { + t.Errorf("expected auth.username testuser, got %v", auth["username"]) } - if basicAuth["password"] != "testpass" { - t.Errorf("expected password testpass, got %v", basicAuth["password"]) + if auth["password"] != "testpass" { + t.Errorf("expected auth.password testpass, got %v", auth["password"]) } }, }, @@ -65,16 +75,16 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - hmac, ok := config["hmac"].(map[string]string) + auth, ok := config["auth"].(map[string]interface{}) if !ok { - t.Errorf("expected hmac map, got %T", config["hmac"]) + t.Errorf("expected auth map, got %T", config["auth"]) return } - if hmac["secret"] != "secret123" { - t.Errorf("expected secret secret123, got %v", hmac["secret"]) + if auth["webhook_secret_key"] != "secret123" { + t.Errorf("expected auth.webhook_secret_key secret123, got %v", auth["webhook_secret_key"]) } - if hmac["algorithm"] != "SHA256" { - t.Errorf("expected algorithm SHA256, got %v", hmac["algorithm"]) + if auth["algorithm"] != "sha256" { + t.Errorf("expected auth.algorithm sha256, got %v", auth["algorithm"]) } }, }, @@ -85,16 +95,16 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - hmac, ok := config["hmac"].(map[string]string) + auth, ok := config["auth"].(map[string]interface{}) if !ok { - t.Errorf("expected hmac map, got %T", config["hmac"]) + t.Errorf("expected auth map, got %T", config["auth"]) return } - if hmac["secret"] != "secret123" { - t.Errorf("expected secret secret123, got %v", hmac["secret"]) + if auth["webhook_secret_key"] != "secret123" { + t.Errorf("expected auth.webhook_secret_key secret123, got %v", auth["webhook_secret_key"]) } - if _, hasAlgo := hmac["algorithm"]; hasAlgo { - t.Errorf("expected no algorithm, got %v", hmac["algorithm"]) + if auth["algorithm"] != "sha256" { + t.Errorf("expected default auth.algorithm sha256, got %v", auth["algorithm"]) } }, }, @@ -178,7 +188,7 @@ func TestBuildSourceConfig(t *testing.T) { cc.SourceAllowedHTTPMethods = "POST,INVALID" }, wantErr: true, - errContains: "invalid HTTP method 'INVALID'", + errContains: "invalid HTTP method", }, { name: "custom response - json content type", @@ -281,7 +291,7 @@ func TestBuildSourceConfig(t *testing.T) { cc.SourceCustomResponseBody = "" }, wantErr: true, - errContains: "invalid content type 'html'", + errContains: "invalid content type", }, { name: "custom response - body exceeds 1000 chars", @@ -333,8 +343,9 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - if config["webhook_secret"] != "whsec_123" { - t.Errorf("expected webhook_secret, got %v", config["webhook_secret"]) + auth, _ := config["auth"].(map[string]interface{}) + if auth == nil || auth["webhook_secret_key"] != "whsec_123" { + t.Errorf("expected auth.webhook_secret_key whsec_123, got %v", config["auth"]) } methods, ok := config["allowed_http_methods"].([]string) if !ok || len(methods) != 2 { @@ -351,8 +362,9 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - if config["api_key"] != "sk_test_123" { - t.Errorf("expected api_key, got %v", config["api_key"]) + auth, _ := config["auth"].(map[string]interface{}) + if auth == nil || auth["api_key"] != "sk_test_123" { + t.Errorf("expected auth.api_key sk_test_123, got %v", config["auth"]) } if config["custom_response"] == nil { t.Errorf("expected custom_response to be set") @@ -369,8 +381,9 @@ func TestBuildSourceConfig(t *testing.T) { }, wantErr: false, validate: func(t *testing.T, config map[string]interface{}) { - if config["webhook_secret"] != "whsec_123" { - t.Errorf("expected webhook_secret") + auth, _ := config["auth"].(map[string]interface{}) + if auth == nil || auth["webhook_secret_key"] != "whsec_123" { + t.Errorf("expected auth.webhook_secret_key whsec_123") } if config["allowed_http_methods"] == nil { t.Errorf("expected allowed_http_methods") diff --git a/pkg/cmd/connection_update.go b/pkg/cmd/connection_update.go new file mode 100644 index 0000000..d407646 --- /dev/null +++ b/pkg/cmd/connection_update.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type connectionUpdateCmd struct { + cmd *cobra.Command + + output string + + // Connection fields (update-by-ID only; no inline source/destination) + name string + description string + sourceID string + destinationID string + + // Rule flags shared with create/upsert + connectionRuleFlags +} + +func newConnectionUpdateCmd() *connectionUpdateCmd { + cu := &connectionUpdateCmd{} + + cu.cmd = &cobra.Command{ + Use: "update ", + Args: validators.ExactArgs(1), + Short: ShortUpdate(ResourceConnection), + Long: LongUpdateIntro(ResourceConnection) + ` + +Unlike upsert (which uses name as identifier), update takes a connection ID +and allows changing any field including the connection name. + +Examples: + # Rename a connection + hookdeck gateway connection update web_abc123 --name "new-name" + + # Update description + hookdeck gateway connection update web_abc123 --description "Updated description" + + # Change the source on a connection + hookdeck gateway connection update web_abc123 --source-id src_def456 + + # Update rules + hookdeck gateway connection update web_abc123 \ + --rule-retry-strategy linear --rule-retry-count 5 + + # Update with JSON output + hookdeck gateway connection update web_abc123 --name "new-name" --output json`, + PreRunE: cu.validateFlags, + RunE: cu.runConnectionUpdateCmd, + } + + // Connection fields + cu.cmd.Flags().StringVar(&cu.name, "name", "", "New connection name") + cu.cmd.Flags().StringVar(&cu.description, "description", "", "Connection description") + + // Resource references + cu.cmd.Flags().StringVar(&cu.sourceID, "source-id", "", "Update source by ID") + cu.cmd.Flags().StringVar(&cu.destinationID, "destination-id", "", "Update destination by ID") + + addConnectionRuleFlags(cu.cmd, &cu.connectionRuleFlags) + + // Output + cu.cmd.Flags().StringVar(&cu.output, "output", "", "Output format (json)") + + return cu +} + +func (cu *connectionUpdateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + return nil +} + +func (cu *connectionUpdateCmd) runConnectionUpdateCmd(cmd *cobra.Command, args []string) error { + connectionID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + req := &hookdeck.ConnectionCreateRequest{} + hasChanges := false + + if cu.name != "" { + req.Name = &cu.name + hasChanges = true + } + + if cu.description != "" { + req.Description = &cu.description + hasChanges = true + } + + if cu.sourceID != "" { + req.SourceID = &cu.sourceID + hasChanges = true + } + + if cu.destinationID != "" { + req.DestinationID = &cu.destinationID + hasChanges = true + } + + // Build rules if any rule flags are set + rules, err := buildConnectionRules(&cu.connectionRuleFlags) + if err != nil { + return err + } + if len(rules) > 0 { + req.Rules = rules + hasChanges = true + } + + if !hasChanges { + // No flags provided; get and display current state + conn, err := client.GetConnection(ctx, connectionID) + if err != nil { + return fmt.Errorf("failed to get connection: %w", err) + } + cu.displayConnection(conn, false) + return nil + } + + conn, err := client.UpdateConnection(ctx, connectionID, req) + if err != nil { + return fmt.Errorf("failed to update connection: %w", err) + } + + cu.displayConnection(conn, true) + return nil +} + +func (cu *connectionUpdateCmd) displayConnection(conn *hookdeck.Connection, updated bool) { + if cu.output == "json" { + jsonBytes, err := json.MarshalIndent(conn, "", " ") + if err != nil { + fmt.Printf("failed to marshal connection to json: %v\n", err) + return + } + fmt.Println(string(jsonBytes)) + return + } + + if updated { + fmt.Println("βœ” Connection updated successfully") + } else { + fmt.Println("No changes specified. Current connection state:") + } + fmt.Println() + + if conn.Name != nil { + fmt.Printf("Connection: %s (%s)\n", *conn.Name, conn.ID) + } else { + fmt.Printf("Connection: (unnamed) (%s)\n", conn.ID) + } + + if conn.Source != nil { + fmt.Printf("Source: %s (%s)\n", conn.Source.Name, conn.Source.ID) + fmt.Printf("Source Type: %s\n", conn.Source.Type) + } + + if conn.Destination != nil { + fmt.Printf("Destination: %s (%s)\n", conn.Destination.Name, conn.Destination.ID) + fmt.Printf("Destination Type: %s\n", conn.Destination.Type) + + switch strings.ToUpper(conn.Destination.Type) { + case "HTTP": + if url := conn.Destination.GetHTTPURL(); url != nil { + fmt.Printf("Destination URL: %s\n", *url) + } + case "CLI": + if path := conn.Destination.GetCLIPath(); path != nil { + fmt.Printf("Destination Path: %s\n", *path) + } + } + } +} + diff --git a/pkg/cmd/connection_upsert.go b/pkg/cmd/connection_upsert.go index 62c7774..ce5a44f 100644 --- a/pkg/cmd/connection_upsert.go +++ b/pkg/cmd/connection_upsert.go @@ -24,9 +24,9 @@ func newConnectionUpsertCmd() *connectionUpsertCmd { cu.cmd = &cobra.Command{ Use: "upsert ", Args: cobra.ExactArgs(1), - Short: "Create or update a connection by name", - Long: `Create a new connection or update an existing one using name as the unique identifier. - + Short: ShortUpsert(ResourceConnection), + Long: LongUpsertIntro(ResourceConnection) + ` + This command is idempotent - it can be safely run multiple times with the same arguments. When the connection doesn't exist: @@ -158,34 +158,7 @@ func newConnectionUpsertCmd() *connectionUpsertCmd { cu.cmd.Flags().IntVar(&cu.DestinationRateLimit, "destination-rate-limit", 0, "Rate limit for destination (requests per period)") cu.cmd.Flags().StringVar(&cu.DestinationRateLimitPeriod, "destination-rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") - // Rule flags - Retry - cu.cmd.Flags().StringVar(&cu.RuleRetryStrategy, "rule-retry-strategy", "", "Retry strategy (linear, exponential)") - cu.cmd.Flags().IntVar(&cu.RuleRetryCount, "rule-retry-count", 0, "Number of retry attempts") - cu.cmd.Flags().IntVar(&cu.RuleRetryInterval, "rule-retry-interval", 0, "Interval between retries in milliseconds") - cu.cmd.Flags().StringVar(&cu.RuleRetryResponseStatusCode, "rule-retry-response-status-codes", "", "Comma-separated HTTP status codes to retry on (e.g., '429,500,502')") - - // Rule flags - Filter - cu.cmd.Flags().StringVar(&cu.RuleFilterBody, "rule-filter-body", "", "JQ expression to filter on request body") - cu.cmd.Flags().StringVar(&cu.RuleFilterHeaders, "rule-filter-headers", "", "JQ expression to filter on request headers") - cu.cmd.Flags().StringVar(&cu.RuleFilterQuery, "rule-filter-query", "", "JQ expression to filter on request query parameters") - cu.cmd.Flags().StringVar(&cu.RuleFilterPath, "rule-filter-path", "", "JQ expression to filter on request path") - - // Rule flags - Transform - cu.cmd.Flags().StringVar(&cu.RuleTransformName, "rule-transform-name", "", "Name or ID of the transformation to apply") - cu.cmd.Flags().StringVar(&cu.RuleTransformCode, "rule-transform-code", "", "Transformation code (if creating inline)") - cu.cmd.Flags().StringVar(&cu.RuleTransformEnv, "rule-transform-env", "", "JSON string representing environment variables for transformation") - - // Rule flags - Delay - cu.cmd.Flags().IntVar(&cu.RuleDelay, "rule-delay", 0, "Delay in milliseconds") - - // Rule flags - Deduplicate - cu.cmd.Flags().IntVar(&cu.RuleDeduplicateWindow, "rule-deduplicate-window", 0, "Time window in seconds for deduplication") - cu.cmd.Flags().StringVar(&cu.RuleDeduplicateIncludeFields, "rule-deduplicate-include-fields", "", "Comma-separated list of fields to include for deduplication") - cu.cmd.Flags().StringVar(&cu.RuleDeduplicateExcludeFields, "rule-deduplicate-exclude-fields", "", "Comma-separated list of fields to exclude for deduplication") - - // Rules JSON fallback - cu.cmd.Flags().StringVar(&cu.Rules, "rules", "", "JSON string representing the entire rules array") - cu.cmd.Flags().StringVar(&cu.RulesFile, "rules-file", "", "Path to a JSON file containing the rules array") + addConnectionRuleFlags(cu.cmd, &cu.connectionCreateCmd.connectionRuleFlags) // Reference existing resources cu.cmd.Flags().StringVar(&cu.sourceID, "source-id", "", "Use existing source by ID") @@ -502,7 +475,7 @@ func (cu *connectionUpsertCmd) buildUpsertRequest(existing *hookdeck.Connection, } // Handle Rules - rules, err := cu.buildRulesArray(nil) + rules, err := buildConnectionRules(&cu.connectionCreateCmd.connectionRuleFlags) if err != nil { return nil, err } diff --git a/pkg/cmd/destination.go b/pkg/cmd/destination.go new file mode 100644 index 0000000..07ab77e --- /dev/null +++ b/pkg/cmd/destination.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationCmd struct { + cmd *cobra.Command +} + +func newDestinationCmd() *destinationCmd { + dc := &destinationCmd{} + + dc.cmd = &cobra.Command{ + Use: "destination", + Aliases: []string{"destinations"}, + Args: validators.NoArgs, + Short: "Manage your destinations", + Long: `Manage webhook and event destinations. + +Destinations define where Hookdeck forwards events. Create destinations with a type (HTTP, CLI, MOCK_API), +optional URL and authentication, then connect them to sources via connections.`, + } + + dc.cmd.AddCommand(newDestinationListCmd().cmd) + dc.cmd.AddCommand(newDestinationGetCmd().cmd) + dc.cmd.AddCommand(newDestinationCreateCmd().cmd) + dc.cmd.AddCommand(newDestinationUpsertCmd().cmd) + dc.cmd.AddCommand(newDestinationUpdateCmd().cmd) + dc.cmd.AddCommand(newDestinationDeleteCmd().cmd) + dc.cmd.AddCommand(newDestinationEnableCmd().cmd) + dc.cmd.AddCommand(newDestinationDisableCmd().cmd) + dc.cmd.AddCommand(newDestinationCountCmd().cmd) + + return dc +} + +// addDestinationCmdTo registers the destination command tree on the given parent (e.g. gateway). +func addDestinationCmdTo(parent *cobra.Command) { + parent.AddCommand(newDestinationCmd().cmd) +} diff --git a/pkg/cmd/destination_common.go b/pkg/cmd/destination_common.go new file mode 100644 index 0000000..049d8d0 --- /dev/null +++ b/pkg/cmd/destination_common.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// destinationConfigFlags holds destination config flags for create/upsert/update. +// Used by destination create, upsert, update. When both --config/--config-file and +// individual flags are set, --config/--config-file take precedence. +type destinationConfigFlags struct { + URL string + CliPath string + AuthMethod string + BearerToken string + BasicAuthUser string + BasicAuthPass string + APIKey string + APIKeyHeader string + APIKeyTo string + CustomSignatureSecret string + CustomSignatureKey string + RateLimit int + RateLimitPeriod string + PathForwardingDisabled *bool + HTTPMethod string +} + +// hasAnyDestinationConfig returns true if any individual destination config flag is set. +func (f *destinationConfigFlags) hasAnyDestinationConfig() bool { + if f == nil { + return false + } + return f.URL != "" || f.CliPath != "" || f.AuthMethod != "" || + f.BearerToken != "" || f.BasicAuthUser != "" || f.BasicAuthPass != "" || + f.APIKey != "" || f.APIKeyHeader != "" || f.CustomSignatureSecret != "" || f.CustomSignatureKey != "" || + f.RateLimit > 0 || f.RateLimitPeriod != "" || f.PathForwardingDisabled != nil || f.HTTPMethod != "" +} + +// buildDestinationAuthConfig builds auth section for destination config from flags. +func buildDestinationAuthConfig(f *destinationConfigFlags) (map[string]interface{}, error) { + if f == nil || f.AuthMethod == "" || f.AuthMethod == "hookdeck" { + return nil, nil + } + auth := make(map[string]interface{}) + switch f.AuthMethod { + case "bearer": + if f.BearerToken == "" { + return nil, fmt.Errorf("--bearer-token is required for bearer auth method") + } + auth["type"] = "BEARER_TOKEN" + auth["token"] = f.BearerToken + case "basic": + if f.BasicAuthUser == "" || f.BasicAuthPass == "" { + return nil, fmt.Errorf("--basic-auth-user and --basic-auth-pass are required for basic auth method") + } + auth["type"] = "BASIC_AUTH" + auth["username"] = f.BasicAuthUser + auth["password"] = f.BasicAuthPass + case "api_key": + if f.APIKey == "" { + return nil, fmt.Errorf("--api-key is required for api_key auth method") + } + if f.APIKeyHeader == "" { + return nil, fmt.Errorf("--api-key-header is required for api_key auth method") + } + auth["type"] = "API_KEY" + auth["api_key"] = f.APIKey + auth["key"] = f.APIKeyHeader + to := f.APIKeyTo + if to == "" { + to = "header" + } + auth["to"] = to + case "custom_signature": + if f.CustomSignatureSecret == "" { + return nil, fmt.Errorf("--custom-signature-secret is required for custom_signature auth method") + } + if f.CustomSignatureKey == "" { + return nil, fmt.Errorf("--custom-signature-key is required for custom_signature auth method") + } + auth["type"] = "CUSTOM_SIGNATURE" + auth["signing_secret"] = f.CustomSignatureSecret + auth["key"] = f.CustomSignatureKey + default: + return nil, fmt.Errorf("unsupported destination auth method: %s (supported: hookdeck, bearer, basic, api_key, custom_signature)", f.AuthMethod) + } + return auth, nil +} + +// buildDestinationConfigFromIndividualFlags builds destination config from flags for the given type. +func buildDestinationConfigFromIndividualFlags(destType string, f *destinationConfigFlags) (map[string]interface{}, error) { + if f == nil { + return make(map[string]interface{}), nil + } + config := make(map[string]interface{}) + + authConfig, err := buildDestinationAuthConfig(f) + if err != nil { + return nil, err + } + if len(authConfig) > 0 { + config["auth_type"] = authConfig["type"] + auth := make(map[string]interface{}) + for k, v := range authConfig { + if k != "type" { + auth[k] = v + } + } + config["auth"] = auth + } + + if f.RateLimit > 0 { + config["rate_limit"] = f.RateLimit + if f.RateLimitPeriod == "" { + return nil, fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + } + config["rate_limit_period"] = f.RateLimitPeriod + } + + switch strings.ToUpper(destType) { + case "HTTP": + if f.URL != "" { + config["url"] = f.URL + } + if f.PathForwardingDisabled != nil { + config["path_forwarding_disabled"] = *f.PathForwardingDisabled + } + if f.HTTPMethod != "" { + valid := map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true} + method := strings.ToUpper(f.HTTPMethod) + if !valid[method] { + return nil, fmt.Errorf("--http-method must be one of: GET, POST, PUT, PATCH, DELETE") + } + config["http_method"] = method + } + case "CLI": + if f.CliPath != "" { + config["path"] = f.CliPath + } + case "MOCK_API": + // no extra fields + default: + if destType != "" { + return nil, fmt.Errorf("unsupported destination type: %s (supported: HTTP, CLI, MOCK_API)", destType) + } + } + + return config, nil +} + +// buildDestinationConfigFromFlags parses destination config from --config/--config-file +// or from individual flags. When configStr or configFile is set, that takes precedence. +// destType is used when building from individual flags (HTTP requires url, etc.). +func buildDestinationConfigFromFlags(configStr, configFile, destType string, individual *destinationConfigFlags) (map[string]interface{}, error) { + if configStr != "" { + var out map[string]interface{} + if err := json.Unmarshal([]byte(configStr), &out); err != nil { + return nil, fmt.Errorf("invalid JSON in --config: %w", err) + } + return out, nil + } + if configFile != "" { + data, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to read --config-file: %w", err) + } + var out map[string]interface{} + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("invalid JSON in config file: %w", err) + } + return out, nil + } + return buildDestinationConfigFromIndividualFlags(destType, individual) +} diff --git a/pkg/cmd/destination_count.go b/pkg/cmd/destination_count.go new file mode 100644 index 0000000..98e8242 --- /dev/null +++ b/pkg/cmd/destination_count.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationCountCmd struct { + cmd *cobra.Command + + name string + destType string + disabled bool +} + +func newDestinationCountCmd() *destinationCountCmd { + dc := &destinationCountCmd{} + + dc.cmd = &cobra.Command{ + Use: "count", + Args: validators.NoArgs, + Short: "Count destinations", + Long: `Count destinations matching optional filters. + +Examples: + hookdeck gateway destination count + hookdeck gateway destination count --type HTTP + hookdeck gateway destination count --disabled`, + RunE: dc.runDestinationCountCmd, + } + + dc.cmd.Flags().StringVar(&dc.name, "name", "", "Filter by destination name") + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Filter by destination type (HTTP, CLI, MOCK_API)") + dc.cmd.Flags().BoolVar(&dc.disabled, "disabled", false, "Count disabled destinations only (when set with other filters)") + + return dc +} + +func (dc *destinationCountCmd) runDestinationCountCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + if dc.name != "" { + params["name"] = dc.name + } + if dc.destType != "" { + params["type"] = dc.destType + } + if dc.disabled { + params["disabled"] = "true" + } else { + params["disabled"] = "false" + } + + resp, err := client.CountDestinations(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to count destinations: %w", err) + } + + fmt.Println(strconv.Itoa(resp.Count)) + return nil +} diff --git a/pkg/cmd/destination_create.go b/pkg/cmd/destination_create.go new file mode 100644 index 0000000..5bc9e33 --- /dev/null +++ b/pkg/cmd/destination_create.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationCreateCmd struct { + cmd *cobra.Command + + name string + description string + destType string + url string + cliPath string + config string + configFile string + output string + + destinationConfigFlags +} + +func newDestinationCreateCmd() *destinationCreateCmd { + dc := &destinationCreateCmd{} + + dc.cmd = &cobra.Command{ + Use: "create", + Args: validators.NoArgs, + Short: ShortCreate(ResourceDestination), + Long: `Create a new destination. + +Requires --name and --type. For HTTP destinations, --url is required. Use --config or --config-file for auth and rate limiting. + +Examples: + hookdeck gateway destination create --name my-api --type HTTP --url https://api.example.com/webhooks + hookdeck gateway destination create --name local-cli --type CLI --cli-path /webhooks + hookdeck gateway destination create --name my-api --type HTTP --url https://api.example.com --bearer-token token123`, + PreRunE: dc.validateFlags, + RunE: dc.runDestinationCreateCmd, + } + + dc.cmd.Flags().StringVar(&dc.name, "name", "", "Destination name (required)") + dc.cmd.Flags().StringVar(&dc.description, "description", "", "Destination description") + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API) (required)") + dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations (required for type HTTP)") + dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "/", "Path for CLI destinations") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") + dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") + dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") + dc.cmd.Flags().StringVar(&dc.BasicAuthPass, "basic-auth-pass", "", "Password for Basic auth") + dc.cmd.Flags().StringVar(&dc.APIKey, "api-key", "", "API key for destination auth") + dc.cmd.Flags().StringVar(&dc.APIKeyHeader, "api-key-header", "", "Header/key name for API key") + dc.cmd.Flags().StringVar(&dc.APIKeyTo, "api-key-to", "header", "Where to send API key (header or query)") + dc.cmd.Flags().StringVar(&dc.CustomSignatureSecret, "custom-signature-secret", "", "Signing secret for custom signature") + dc.cmd.Flags().StringVar(&dc.CustomSignatureKey, "custom-signature-key", "", "Key/header name for custom signature") + dc.cmd.Flags().IntVar(&dc.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") + dc.cmd.Flags().StringVar(&dc.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + dc.cmd.Flags().StringVar(&dc.HTTPMethod, "http-method", "", "HTTP method for HTTP destinations (GET, POST, PUT, PATCH, DELETE)") + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + dc.cmd.MarkFlagRequired("name") + dc.cmd.MarkFlagRequired("type") + + return dc +} + +func (dc *destinationCreateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if dc.config != "" && dc.configFile != "" { + return fmt.Errorf("cannot use both --config and --config-file") + } + t := strings.ToUpper(dc.destType) + if t == "HTTP" && dc.url == "" && dc.config == "" && dc.configFile == "" { + return fmt.Errorf("--url is required for HTTP destinations") + } + if dc.RateLimit > 0 && dc.RateLimitPeriod == "" { + return fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + } + return nil +} + +func (dc *destinationCreateCmd) runDestinationCreateCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + // Sync url/cliPath into flags for buildDestinationConfigFromIndividualFlags when not using --config + dc.destinationConfigFlags.URL = dc.url + dc.destinationConfigFlags.CliPath = dc.cliPath + + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + if err != nil { + return err + } + + // For HTTP/CLI, ensure url/path in config when using individual flags + t := strings.ToUpper(dc.destType) + if config == nil { + config = make(map[string]interface{}) + } + if t == "HTTP" && dc.url != "" { + config["url"] = dc.url + } + if t == "CLI" { + path := dc.cliPath + if path == "" { + path = "/" + } + config["path"] = path + } + + req := &hookdeck.DestinationCreateRequest{ + Name: dc.name, + Type: t, + } + if dc.description != "" { + req.Description = &dc.description + } + if len(config) > 0 { + req.Config = config + } + + dst, err := client.CreateDestination(ctx, req) + if err != nil { + return fmt.Errorf("failed to create destination: %w", err) + } + + if dc.output == "json" { + jsonBytes, err := json.MarshalIndent(dst, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal destination to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Destination created successfully\n\n") + fmt.Printf("Destination: %s (%s)\n", dst.Name, dst.ID) + fmt.Printf("Type: %s\n", dst.Type) + if u := dst.GetHTTPURL(); u != nil { + fmt.Printf("URL: %s\n", *u) + } + return nil +} diff --git a/pkg/cmd/destination_delete.go b/pkg/cmd/destination_delete.go new file mode 100644 index 0000000..a4b4a44 --- /dev/null +++ b/pkg/cmd/destination_delete.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationDeleteCmd struct { + cmd *cobra.Command + force bool +} + +func newDestinationDeleteCmd() *destinationDeleteCmd { + dc := &destinationDeleteCmd{} + + dc.cmd = &cobra.Command{ + Use: "delete ", + Args: validators.ExactArgs(1), + Short: ShortDelete(ResourceDestination), + Long: LongDeleteIntro(ResourceDestination) + ` + +Examples: + hookdeck gateway destination delete des_abc123 + hookdeck gateway destination delete des_abc123 --force`, + PreRunE: dc.validateFlags, + RunE: dc.runDestinationDeleteCmd, + } + + dc.cmd.Flags().BoolVar(&dc.force, "force", false, "Force delete without confirmation") + + return dc +} + +func (dc *destinationDeleteCmd) validateFlags(cmd *cobra.Command, args []string) error { + return Config.Profile.ValidateAPIKey() +} + +func (dc *destinationDeleteCmd) runDestinationDeleteCmd(cmd *cobra.Command, args []string) error { + destID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + dst, err := client.GetDestination(ctx, destID, nil) + if err != nil { + return fmt.Errorf("failed to get destination: %w", err) + } + + if !dc.force { + fmt.Printf("\nAre you sure you want to delete destination '%s' (%s)? [y/N]: ", dst.Name, destID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" { + fmt.Println("Deletion cancelled.") + return nil + } + } + + if err := client.DeleteDestination(ctx, destID); err != nil { + return fmt.Errorf("failed to delete destination: %w", err) + } + + fmt.Printf("βœ” Destination deleted: %s (%s)\n", dst.Name, destID) + return nil +} diff --git a/pkg/cmd/destination_disable.go b/pkg/cmd/destination_disable.go new file mode 100644 index 0000000..c96c517 --- /dev/null +++ b/pkg/cmd/destination_disable.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationDisableCmd struct { + cmd *cobra.Command +} + +func newDestinationDisableCmd() *destinationDisableCmd { + dc := &destinationDisableCmd{} + + dc.cmd = &cobra.Command{ + Use: "disable ", + Args: validators.ExactArgs(1), + Short: ShortDisable(ResourceDestination), + Long: LongDisableIntro(ResourceDestination), + RunE: dc.runDestinationDisableCmd, + } + + return dc +} + +func (dc *destinationDisableCmd) runDestinationDisableCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + ctx := context.Background() + + dst, err := client.DisableDestination(ctx, args[0]) + if err != nil { + return fmt.Errorf("failed to disable destination: %w", err) + } + + fmt.Printf("βœ“ Destination disabled: %s (%s)\n", dst.Name, dst.ID) + return nil +} diff --git a/pkg/cmd/destination_enable.go b/pkg/cmd/destination_enable.go new file mode 100644 index 0000000..25a4fae --- /dev/null +++ b/pkg/cmd/destination_enable.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationEnableCmd struct { + cmd *cobra.Command +} + +func newDestinationEnableCmd() *destinationEnableCmd { + dc := &destinationEnableCmd{} + + dc.cmd = &cobra.Command{ + Use: "enable ", + Args: validators.ExactArgs(1), + Short: ShortEnable(ResourceDestination), + Long: LongEnableIntro(ResourceDestination), + RunE: dc.runDestinationEnableCmd, + } + + return dc +} + +func (dc *destinationEnableCmd) runDestinationEnableCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + ctx := context.Background() + + dst, err := client.EnableDestination(ctx, args[0]) + if err != nil { + return fmt.Errorf("failed to enable destination: %w", err) + } + + fmt.Printf("βœ“ Destination enabled: %s (%s)\n", dst.Name, dst.ID) + return nil +} diff --git a/pkg/cmd/destination_get.go b/pkg/cmd/destination_get.go new file mode 100644 index 0000000..aecd44f --- /dev/null +++ b/pkg/cmd/destination_get.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationGetCmd struct { + cmd *cobra.Command + + output string + includeDestAuth bool +} + +func newDestinationGetCmd() *destinationGetCmd { + dc := &destinationGetCmd{} + + dc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceDestination), + Long: LongGetIntro(ResourceDestination) + ` + +Examples: + hookdeck gateway destination get des_abc123 + hookdeck gateway destination get my-destination --include-auth`, + RunE: dc.runDestinationGetCmd, + } + + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + addIncludeAuthFlagForDestination(dc.cmd, &dc.includeDestAuth) + + return dc +} + +func (dc *destinationGetCmd) runDestinationGetCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + idOrName := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + destID, err := resolveDestinationID(ctx, client, idOrName) + if err != nil { + return err + } + + params := includeAuthParams(dc.includeDestAuth) + + dst, err := client.GetDestination(ctx, destID, params) + if err != nil { + return fmt.Errorf("failed to get destination: %w", err) + } + + if dc.output == "json" { + jsonBytes, err := json.MarshalIndent(dst, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal destination to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(dst.Name)) + fmt.Printf(" ID: %s\n", dst.ID) + fmt.Printf(" Type: %s\n", dst.Type) + if url := dst.GetHTTPURL(); url != nil { + fmt.Printf(" URL: %s\n", *url) + } + if path := dst.GetCLIPath(); path != nil { + fmt.Printf(" Path: %s\n", *path) + } + if dst.Description != nil && *dst.Description != "" { + fmt.Printf(" Description: %s\n", *dst.Description) + } + if dst.DisabledAt != nil { + fmt.Printf(" Status: %s\n", color.Red("disabled")) + } else { + fmt.Printf(" Status: %s\n", color.Green("active")) + } + fmt.Printf(" Created: %s\n", dst.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Updated: %s\n", dst.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + + return nil +} + +// resolveDestinationID returns the destination ID for the given name or ID. +func resolveDestinationID(ctx context.Context, client *hookdeck.Client, nameOrID string) (string, error) { + if strings.HasPrefix(nameOrID, "des_") { + _, err := client.GetDestination(ctx, nameOrID, nil) + if err == nil { + return nameOrID, nil + } + errMsg := strings.ToLower(err.Error()) + if !strings.Contains(errMsg, "404") && !strings.Contains(errMsg, "not found") { + return "", err + } + } + + params := map[string]string{"name": nameOrID} + result, err := client.ListDestinations(ctx, params) + if err != nil { + return "", fmt.Errorf("failed to lookup destination by name '%s': %w", nameOrID, err) + } + if len(result.Models) == 0 { + return "", fmt.Errorf("no destination found with name or ID '%s'", nameOrID) + } + return result.Models[0].ID, nil +} diff --git a/pkg/cmd/destination_list.go b/pkg/cmd/destination_list.go new file mode 100644 index 0000000..57a4997 --- /dev/null +++ b/pkg/cmd/destination_list.go @@ -0,0 +1,115 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationListCmd struct { + cmd *cobra.Command + + name string + destType string + disabled bool + limit int + output string +} + +func newDestinationListCmd() *destinationListCmd { + dc := &destinationListCmd{} + + dc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceDestination), + Long: `List all destinations or filter by name or type. + +Examples: + hookdeck gateway destination list + hookdeck gateway destination list --name my-destination + hookdeck gateway destination list --type HTTP + hookdeck gateway destination list --disabled + hookdeck gateway destination list --limit 10`, + RunE: dc.runDestinationListCmd, + } + + dc.cmd.Flags().StringVar(&dc.name, "name", "", "Filter by destination name") + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Filter by destination type (HTTP, CLI, MOCK_API)") + dc.cmd.Flags().BoolVar(&dc.disabled, "disabled", false, "Include disabled destinations") + dc.cmd.Flags().IntVar(&dc.limit, "limit", 100, "Limit number of results") + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *destinationListCmd) runDestinationListCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + + if dc.name != "" { + params["name"] = dc.name + } + if dc.destType != "" { + params["type"] = dc.destType + } + if dc.disabled { + params["disabled"] = "true" + } else { + params["disabled"] = "false" + } + params["limit"] = strconv.Itoa(dc.limit) + + resp, err := client.ListDestinations(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list destinations: %w", err) + } + + if dc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal destinations to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No destinations found.") + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\nFound %d destination(s):\n\n", len(resp.Models)) + for _, d := range resp.Models { + fmt.Printf("%s\n", color.Green(d.Name)) + fmt.Printf(" ID: %s\n", d.ID) + fmt.Printf(" Type: %s\n", d.Type) + if url := d.GetHTTPURL(); url != nil { + fmt.Printf(" URL: %s\n", *url) + } + if d.DisabledAt != nil { + fmt.Printf(" Status: %s\n", color.Red("disabled")) + } else { + fmt.Printf(" Status: %s\n", color.Green("active")) + } + fmt.Println() + } + + return nil +} diff --git a/pkg/cmd/destination_update.go b/pkg/cmd/destination_update.go new file mode 100644 index 0000000..1d9dbc0 --- /dev/null +++ b/pkg/cmd/destination_update.go @@ -0,0 +1,137 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationUpdateCmd struct { + cmd *cobra.Command + + name string + description string + destType string + url string + cliPath string + config string + configFile string + output string + + destinationConfigFlags +} + +func newDestinationUpdateCmd() *destinationUpdateCmd { + dc := &destinationUpdateCmd{} + + dc.cmd = &cobra.Command{ + Use: "update ", + Args: validators.ExactArgs(1), + Short: ShortUpdate(ResourceDestination), + Long: LongUpdateIntro(ResourceDestination) + ` + +Examples: + hookdeck gateway destination update des_abc123 --name new-name + hookdeck gateway destination update des_abc123 --description "Updated" + hookdeck gateway destination update des_abc123 --url https://api.example.com/new`, + PreRunE: dc.validateFlags, + RunE: dc.runDestinationUpdateCmd, + } + + dc.cmd.Flags().StringVar(&dc.name, "name", "", "New destination name") + dc.cmd.Flags().StringVar(&dc.description, "description", "", "New destination description") + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API)") + dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations") + dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "", "Path for CLI destinations") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") + dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") + dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") + dc.cmd.Flags().StringVar(&dc.BasicAuthPass, "basic-auth-pass", "", "Password for Basic auth") + dc.cmd.Flags().StringVar(&dc.APIKey, "api-key", "", "API key for destination auth") + dc.cmd.Flags().StringVar(&dc.APIKeyHeader, "api-key-header", "", "Header/key name for API key") + dc.cmd.Flags().StringVar(&dc.APIKeyTo, "api-key-to", "header", "Where to send API key (header or query)") + dc.cmd.Flags().StringVar(&dc.CustomSignatureSecret, "custom-signature-secret", "", "Signing secret for custom signature") + dc.cmd.Flags().StringVar(&dc.CustomSignatureKey, "custom-signature-key", "", "Key/header name for custom signature") + dc.cmd.Flags().IntVar(&dc.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") + dc.cmd.Flags().StringVar(&dc.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + dc.cmd.Flags().StringVar(&dc.HTTPMethod, "http-method", "", "HTTP method for HTTP destinations") + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func destinationUpdateRequestEmpty(req *hookdeck.DestinationUpdateRequest) bool { + return req.Name == "" && req.Description == nil && req.Type == "" && len(req.Config) == 0 +} + +func (dc *destinationUpdateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if dc.config != "" && dc.configFile != "" { + return fmt.Errorf("cannot use both --config and --config-file") + } + if dc.RateLimit > 0 && dc.RateLimitPeriod == "" { + return fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + } + return nil +} + +func (dc *destinationUpdateCmd) runDestinationUpdateCmd(cmd *cobra.Command, args []string) error { + destID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + dc.destinationConfigFlags.URL = dc.url + dc.destinationConfigFlags.CliPath = dc.cliPath + + req := &hookdeck.DestinationUpdateRequest{} + req.Name = dc.name + if dc.description != "" { + req.Description = &dc.description + } + if dc.destType != "" { + req.Type = strings.ToUpper(dc.destType) + } + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + if err != nil { + return err + } + if len(config) > 0 { + req.Config = config + } + + if destinationUpdateRequestEmpty(req) { + return fmt.Errorf("no updates specified (set at least one of --name, --description, --type, or config flags)") + } + + dst, err := client.UpdateDestination(ctx, destID, req) + if err != nil { + return fmt.Errorf("failed to update destination: %w", err) + } + + if dc.output == "json" { + jsonBytes, err := json.MarshalIndent(dst, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal destination to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Destination updated successfully\n\n") + fmt.Printf("Destination: %s (%s)\n", dst.Name, dst.ID) + fmt.Printf("Type: %s\n", dst.Type) + if u := dst.GetHTTPURL(); u != nil { + fmt.Printf("URL: %s\n", *u) + } + return nil +} diff --git a/pkg/cmd/destination_upsert.go b/pkg/cmd/destination_upsert.go new file mode 100644 index 0000000..d7fe6fa --- /dev/null +++ b/pkg/cmd/destination_upsert.go @@ -0,0 +1,172 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type destinationUpsertCmd struct { + cmd *cobra.Command + + name string + description string + destType string + url string + cliPath string + config string + configFile string + dryRun bool + output string + + destinationConfigFlags +} + +func newDestinationUpsertCmd() *destinationUpsertCmd { + dc := &destinationUpsertCmd{} + + dc.cmd = &cobra.Command{ + Use: "upsert ", + Args: validators.ExactArgs(1), + Short: ShortUpsert(ResourceDestination), + Long: LongUpsertIntro(ResourceDestination) + ` + +Examples: + hookdeck gateway destination upsert my-api --type HTTP --url https://api.example.com/webhooks + hookdeck gateway destination upsert local-cli --type CLI --cli-path /webhooks + hookdeck gateway destination upsert my-api --description "Updated" --dry-run`, + PreRunE: dc.validateFlags, + RunE: dc.runDestinationUpsertCmd, + } + + dc.cmd.Flags().StringVar(&dc.description, "description", "", "Destination description") + dc.cmd.Flags().StringVar(&dc.destType, "type", "", "Destination type (HTTP, CLI, MOCK_API)") + dc.cmd.Flags().StringVar(&dc.url, "url", "", "URL for HTTP destinations") + dc.cmd.Flags().StringVar(&dc.cliPath, "cli-path", "", "Path for CLI destinations") + dc.cmd.Flags().StringVar(&dc.config, "config", "", "JSON object for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.configFile, "config-file", "", "Path to JSON file for destination config (overrides individual flags if set)") + dc.cmd.Flags().StringVar(&dc.AuthMethod, "auth-method", "", "Auth method (hookdeck, bearer, basic, api_key, custom_signature)") + dc.cmd.Flags().StringVar(&dc.BearerToken, "bearer-token", "", "Bearer token for destination auth") + dc.cmd.Flags().StringVar(&dc.BasicAuthUser, "basic-auth-user", "", "Username for Basic auth") + dc.cmd.Flags().StringVar(&dc.BasicAuthPass, "basic-auth-pass", "", "Password for Basic auth") + dc.cmd.Flags().StringVar(&dc.APIKey, "api-key", "", "API key for destination auth") + dc.cmd.Flags().StringVar(&dc.APIKeyHeader, "api-key-header", "", "Header/key name for API key") + dc.cmd.Flags().StringVar(&dc.APIKeyTo, "api-key-to", "header", "Where to send API key (header or query)") + dc.cmd.Flags().StringVar(&dc.CustomSignatureSecret, "custom-signature-secret", "", "Signing secret for custom signature") + dc.cmd.Flags().StringVar(&dc.CustomSignatureKey, "custom-signature-key", "", "Key/header name for custom signature") + dc.cmd.Flags().IntVar(&dc.RateLimit, "rate-limit", 0, "Rate limit (requests per period)") + dc.cmd.Flags().StringVar(&dc.RateLimitPeriod, "rate-limit-period", "", "Rate limit period (second, minute, hour, concurrent)") + dc.cmd.Flags().StringVar(&dc.HTTPMethod, "http-method", "", "HTTP method for HTTP destinations") + dc.cmd.Flags().BoolVar(&dc.dryRun, "dry-run", false, "Preview changes without applying") + dc.cmd.Flags().StringVar(&dc.output, "output", "", "Output format (json)") + + return dc +} + +func (dc *destinationUpsertCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + dc.name = args[0] + if dc.config != "" && dc.configFile != "" { + return fmt.Errorf("cannot use both --config and --config-file") + } + if dc.RateLimit > 0 && dc.RateLimitPeriod == "" { + return fmt.Errorf("--rate-limit-period is required when --rate-limit is set") + } + return nil +} + +func (dc *destinationUpsertCmd) runDestinationUpsertCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + dc.destinationConfigFlags.URL = dc.url + dc.destinationConfigFlags.CliPath = dc.cliPath + + config, err := buildDestinationConfigFromFlags(dc.config, dc.configFile, dc.destType, &dc.destinationConfigFlags) + if err != nil { + return err + } + + t := strings.ToUpper(dc.destType) + if config == nil { + config = make(map[string]interface{}) + } + if t == "HTTP" && dc.url != "" { + config["url"] = dc.url + } + if t == "CLI" && dc.cliPath != "" { + config["path"] = dc.cliPath + } + + req := &hookdeck.DestinationCreateRequest{ + Name: dc.name, + } + if dc.description != "" { + req.Description = &dc.description + } + if t != "" { + req.Type = t + } + if len(config) > 0 { + req.Config = config + } + + // API requires config on PUT. When doing partial update (e.g. only --description), fetch existing and merge. + if req.Config == nil || len(req.Config) == 0 { + params := map[string]string{"name": dc.name} + listResp, err := client.ListDestinations(ctx, params) + if err == nil && listResp.Models != nil && len(listResp.Models) > 0 { + existing, err := client.GetDestination(ctx, listResp.Models[0].ID, nil) + if err == nil && existing.Config != nil { + req.Config = existing.Config + if req.Type == "" { + req.Type = existing.Type + } + } + } + } + + if dc.dryRun { + params := map[string]string{"name": dc.name} + existing, err := client.ListDestinations(ctx, params) + if err != nil { + return fmt.Errorf("dry-run: failed to check existing destination: %w", err) + } + if existing.Models != nil && len(existing.Models) > 0 { + fmt.Printf("-- Dry Run: UPDATE --\nDestination '%s' (%s) would be updated.\n", dc.name, existing.Models[0].ID) + } else { + fmt.Printf("-- Dry Run: CREATE --\nDestination '%s' would be created.\n", dc.name) + } + return nil + } + + dst, err := client.UpsertDestination(ctx, req) + if err != nil { + return fmt.Errorf("failed to upsert destination: %w", err) + } + + if dc.output == "json" { + jsonBytes, err := json.MarshalIndent(dst, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal destination to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Destination upserted successfully\n\n") + fmt.Printf("Destination: %s (%s)\n", dst.Name, dst.ID) + fmt.Printf("Type: %s\n", dst.Type) + if u := dst.GetHTTPURL(); u != nil { + fmt.Printf("URL: %s\n", *u) + } + return nil +} diff --git a/pkg/cmd/event.go b/pkg/cmd/event.go new file mode 100644 index 0000000..3b6137c --- /dev/null +++ b/pkg/cmd/event.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventCmd struct { + cmd *cobra.Command +} + +func newEventCmd() *eventCmd { + ec := &eventCmd{} + + ec.cmd = &cobra.Command{ + Use: "event", + Aliases: []string{"events"}, + Args: validators.NoArgs, + Short: "Inspect and manage events", + Long: `List, get, retry, cancel, or mute events (processed webhook deliveries). +Filter by connection ID (--connection-id), status, source, or destination.`, + } + + ec.cmd.AddCommand(newEventListCmd().cmd) + ec.cmd.AddCommand(newEventGetCmd().cmd) + ec.cmd.AddCommand(newEventRawBodyCmd().cmd) + ec.cmd.AddCommand(newEventRetryCmd().cmd) + ec.cmd.AddCommand(newEventCancelCmd().cmd) + ec.cmd.AddCommand(newEventMuteCmd().cmd) + + return ec +} + +func addEventCmdTo(parent *cobra.Command) { + parent.AddCommand(newEventCmd().cmd) +} diff --git a/pkg/cmd/event_cancel.go b/pkg/cmd/event_cancel.go new file mode 100644 index 0000000..c2bc61a --- /dev/null +++ b/pkg/cmd/event_cancel.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventCancelCmd struct { + cmd *cobra.Command +} + +func newEventCancelCmd() *eventCancelCmd { + ec := &eventCancelCmd{} + + ec.cmd = &cobra.Command{ + Use: "cancel ", + Args: validators.ExactArgs(1), + Short: "Cancel an event", + Long: `Cancel an event by ID. Cancelled events will not be retried. + +Examples: + hookdeck gateway event cancel evt_abc123`, + RunE: ec.runEventCancelCmd, + } + + return ec +} + +func (ec *eventCancelCmd) runEventCancelCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + eventID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + if err := client.CancelEvent(ctx, eventID); err != nil { + return fmt.Errorf("failed to cancel event: %w", err) + } + fmt.Printf("Event %s cancelled.\n", eventID) + return nil +} diff --git a/pkg/cmd/event_get.go b/pkg/cmd/event_get.go new file mode 100644 index 0000000..dc1f927 --- /dev/null +++ b/pkg/cmd/event_get.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventGetCmd struct { + cmd *cobra.Command + output string +} + +func newEventGetCmd() *eventGetCmd { + ec := &eventGetCmd{} + + ec.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceEvent), + Long: `Get detailed information about an event by ID. + +Examples: + hookdeck gateway event get evt_abc123`, + RunE: ec.runEventGetCmd, + } + + ec.cmd.Flags().StringVar(&ec.output, "output", "", "Output format (json)") + + return ec +} + +func (ec *eventGetCmd) runEventGetCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + eventID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + event, err := client.GetEvent(ctx, eventID, nil) + if err != nil { + return fmt.Errorf("failed to get event: %w", err) + } + + if ec.output == "json" { + jsonBytes, err := json.MarshalIndent(event, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal event to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(event.ID)) + fmt.Printf(" Status: %s\n", event.Status) + fmt.Printf(" Connection ID: %s\n", event.WebhookID) + fmt.Printf(" Source ID: %s\n", event.SourceID) + fmt.Printf(" Destination ID: %s\n", event.DestinationID) + fmt.Printf(" Request ID: %s\n", event.RequestID) + fmt.Printf(" Attempts: %d\n", event.Attempts) + if event.ResponseStatus != nil { + fmt.Printf(" Response: %d\n", *event.ResponseStatus) + } + if event.ErrorCode != nil { + fmt.Printf(" Error code: %s\n", *event.ErrorCode) + } + fmt.Printf(" Created: %s\n", event.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + return nil +} diff --git a/pkg/cmd/event_list.go b/pkg/cmd/event_list.go new file mode 100644 index 0000000..6833860 --- /dev/null +++ b/pkg/cmd/event_list.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventListCmd struct { + cmd *cobra.Command + + id string + connectionID string + sourceID string + destinationID string + status string + attempts string + responseStatus string + errorCode string + cliID string + issueID string + createdAfter string + createdBefore string + successfulAfter string + successfulBefore string + lastAttemptAfter string + lastAttemptBefore string + headers string + body string + path string + parsedQuery string + orderBy string + dir string + limit int + next string + prev string + output string +} + +func newEventListCmd() *eventListCmd { + ec := &eventListCmd{} + + ec.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceEvent), + Long: `List events (processed webhook deliveries). Filter by connection ID, source, destination, or status. + +Examples: + hookdeck gateway event list + hookdeck gateway event list --connection-id web_abc123 + hookdeck gateway event list --status FAILED --limit 20`, + RunE: ec.runEventListCmd, + } + + ec.cmd.Flags().StringVar(&ec.id, "id", "", "Filter by event ID(s) (comma-separated)") + ec.cmd.Flags().StringVar(&ec.connectionID, "connection-id", "", "Filter by connection ID") + ec.cmd.Flags().StringVar(&ec.sourceID, "source-id", "", "Filter by source ID") + ec.cmd.Flags().StringVar(&ec.destinationID, "destination-id", "", "Filter by destination ID") + ec.cmd.Flags().StringVar(&ec.status, "status", "", "Filter by status (SCHEDULED, QUEUED, HOLD, SUCCESSFUL, FAILED, CANCELLED)") + ec.cmd.Flags().StringVar(&ec.attempts, "attempts", "", "Filter by number of attempts (integer or operators)") + ec.cmd.Flags().StringVar(&ec.responseStatus, "response-status", "", "Filter by HTTP response status (e.g. 200, 500)") + ec.cmd.Flags().StringVar(&ec.errorCode, "error-code", "", "Filter by error code") + ec.cmd.Flags().StringVar(&ec.cliID, "cli-id", "", "Filter by CLI ID") + ec.cmd.Flags().StringVar(&ec.issueID, "issue-id", "", "Filter by issue ID") + ec.cmd.Flags().StringVar(&ec.createdAfter, "created-after", "", "Filter events created after (ISO date-time)") + ec.cmd.Flags().StringVar(&ec.createdBefore, "created-before", "", "Filter events created before (ISO date-time)") + ec.cmd.Flags().StringVar(&ec.successfulAfter, "successful-at-after", "", "Filter by successful_at after (ISO date-time)") + ec.cmd.Flags().StringVar(&ec.successfulBefore, "successful-at-before", "", "Filter by successful_at before (ISO date-time)") + ec.cmd.Flags().StringVar(&ec.lastAttemptAfter, "last-attempt-at-after", "", "Filter by last_attempt_at after (ISO date-time)") + ec.cmd.Flags().StringVar(&ec.lastAttemptBefore, "last-attempt-at-before", "", "Filter by last_attempt_at before (ISO date-time)") + ec.cmd.Flags().StringVar(&ec.headers, "headers", "", "Filter by headers (JSON string)") + ec.cmd.Flags().StringVar(&ec.body, "body", "", "Filter by body (JSON string)") + ec.cmd.Flags().StringVar(&ec.path, "path", "", "Filter by path") + ec.cmd.Flags().StringVar(&ec.parsedQuery, "parsed-query", "", "Filter by parsed query (JSON string)") + ec.cmd.Flags().StringVar(&ec.orderBy, "order-by", "", "Sort key (e.g. created_at)") + ec.cmd.Flags().StringVar(&ec.dir, "dir", "", "Sort direction (asc, desc)") + ec.cmd.Flags().IntVar(&ec.limit, "limit", 100, "Limit number of results") + ec.cmd.Flags().StringVar(&ec.next, "next", "", "Pagination cursor for next page") + ec.cmd.Flags().StringVar(&ec.prev, "prev", "", "Pagination cursor for previous page") + ec.cmd.Flags().StringVar(&ec.output, "output", "", "Output format (json)") + + return ec +} + +func (ec *eventListCmd) runEventListCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + if ec.id != "" { + params["id"] = ec.id + } + if ec.connectionID != "" { + params["webhook_id"] = ec.connectionID + } + if ec.sourceID != "" { + params["source_id"] = ec.sourceID + } + if ec.destinationID != "" { + params["destination_id"] = ec.destinationID + } + if ec.status != "" { + params["status"] = ec.status + } + if ec.attempts != "" { + params["attempts"] = ec.attempts + } + if ec.responseStatus != "" { + params["response_status"] = ec.responseStatus + } + if ec.errorCode != "" { + params["error_code"] = ec.errorCode + } + if ec.cliID != "" { + params["cli_id"] = ec.cliID + } + if ec.issueID != "" { + params["issue_id"] = ec.issueID + } + if ec.createdAfter != "" { + params["created_at[gte]"] = ec.createdAfter + } + if ec.createdBefore != "" { + params["created_at[lte]"] = ec.createdBefore + } + if ec.successfulAfter != "" { + params["successful_at[gte]"] = ec.successfulAfter + } + if ec.successfulBefore != "" { + params["successful_at[lte]"] = ec.successfulBefore + } + if ec.lastAttemptAfter != "" { + params["last_attempt_at[gte]"] = ec.lastAttemptAfter + } + if ec.lastAttemptBefore != "" { + params["last_attempt_at[lte]"] = ec.lastAttemptBefore + } + if ec.headers != "" { + params["headers"] = ec.headers + } + if ec.body != "" { + params["body"] = ec.body + } + if ec.path != "" { + params["path"] = ec.path + } + if ec.parsedQuery != "" { + params["parsed_query"] = ec.parsedQuery + } + if ec.orderBy != "" { + params["order_by"] = ec.orderBy + } + if ec.dir != "" { + params["dir"] = ec.dir + } + params["limit"] = strconv.Itoa(ec.limit) + if ec.next != "" { + params["next"] = ec.next + } + if ec.prev != "" { + params["prev"] = ec.prev + } + + resp, err := client.ListEvents(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list events: %w", err) + } + + if ec.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal events to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No events found.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, e := range resp.Models { + fmt.Printf("%s %s %s\n", color.Green(e.ID), e.Status, e.WebhookID) + } + return nil +} diff --git a/pkg/cmd/event_mute.go b/pkg/cmd/event_mute.go new file mode 100644 index 0000000..e46f6d7 --- /dev/null +++ b/pkg/cmd/event_mute.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventMuteCmd struct { + cmd *cobra.Command +} + +func newEventMuteCmd() *eventMuteCmd { + ec := &eventMuteCmd{} + + ec.cmd = &cobra.Command{ + Use: "mute ", + Args: validators.ExactArgs(1), + Short: "Mute an event", + Long: `Mute an event by ID. Muted events will not trigger alerts or retries. + +Examples: + hookdeck gateway event mute evt_abc123`, + RunE: ec.runEventMuteCmd, + } + + return ec +} + +func (ec *eventMuteCmd) runEventMuteCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + eventID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + if err := client.MuteEvent(ctx, eventID); err != nil { + return fmt.Errorf("failed to mute event: %w", err) + } + fmt.Printf("Event %s muted.\n", eventID) + return nil +} diff --git a/pkg/cmd/event_raw_body.go b/pkg/cmd/event_raw_body.go new file mode 100644 index 0000000..2245f43 --- /dev/null +++ b/pkg/cmd/event_raw_body.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventRawBodyCmd struct { + cmd *cobra.Command +} + +func newEventRawBodyCmd() *eventRawBodyCmd { + ec := &eventRawBodyCmd{} + + ec.cmd = &cobra.Command{ + Use: "raw-body ", + Args: validators.ExactArgs(1), + Short: "Get raw body of an event", + Long: `Output the raw request body of an event by ID. + +Examples: + hookdeck gateway event raw-body evt_abc123`, + RunE: ec.runEventRawBodyCmd, + } + + return ec +} + +func (ec *eventRawBodyCmd) runEventRawBodyCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + eventID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + body, err := client.GetEventRawBody(ctx, eventID) + if err != nil { + return fmt.Errorf("failed to get event raw body: %w", err) + } + _, _ = os.Stdout.Write(body) + return nil +} diff --git a/pkg/cmd/event_retry.go b/pkg/cmd/event_retry.go new file mode 100644 index 0000000..726c3c4 --- /dev/null +++ b/pkg/cmd/event_retry.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type eventRetryCmd struct { + cmd *cobra.Command +} + +func newEventRetryCmd() *eventRetryCmd { + ec := &eventRetryCmd{} + + ec.cmd = &cobra.Command{ + Use: "retry ", + Args: validators.ExactArgs(1), + Short: "Retry an event", + Long: `Retry delivery for an event by ID. + +Examples: + hookdeck gateway event retry evt_abc123`, + RunE: ec.runEventRetryCmd, + } + + return ec +} + +func (ec *eventRetryCmd) runEventRetryCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + eventID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + if err := client.RetryEvent(ctx, eventID); err != nil { + return fmt.Errorf("failed to retry event: %w", err) + } + fmt.Printf("Event %s retry requested.\n", eventID) + return nil +} diff --git a/pkg/cmd/gateway.go b/pkg/cmd/gateway.go new file mode 100644 index 0000000..3e5b9a0 --- /dev/null +++ b/pkg/cmd/gateway.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type gatewayCmd struct { + cmd *cobra.Command +} + +func newGatewayCmd() *gatewayCmd { + g := &gatewayCmd{} + + g.cmd = &cobra.Command{ + Use: "gateway", + Args: validators.NoArgs, + Short: "Manage Hookdeck Event Gateway resources", + Long: `Commands for managing Event Gateway sources, destinations, connections, +transformations, events, requests, and metrics. + +The gateway command group provides full access to all Event Gateway resources.`, + Example: ` # List connections + hookdeck gateway connection list + + # Create a source + hookdeck gateway source create --name my-source --type WEBHOOK + + # Query event metrics + hookdeck gateway metrics events --start 2026-01-01T00:00:00Z --end 2026-02-01T00:00:00Z`, + } + + // Register resource subcommands (same factory as root backward-compat registration) + addConnectionCmdTo(g.cmd) + addSourceCmdTo(g.cmd) + addDestinationCmdTo(g.cmd) + addTransformationCmdTo(g.cmd) + addEventCmdTo(g.cmd) + addRequestCmdTo(g.cmd) + addAttemptCmdTo(g.cmd) + + return g +} diff --git a/pkg/cmd/helptext.go b/pkg/cmd/helptext.go new file mode 100644 index 0000000..66accad --- /dev/null +++ b/pkg/cmd/helptext.go @@ -0,0 +1,53 @@ +package cmd + +// Resource names for shared help text (singular form for "a source", "a connection"). +const ( + ResourceSource = "source" + ResourceConnection = "connection" + ResourceDestination = "destination" + ResourceTransformation = "transformation" + ResourceEvent = "event" + ResourceRequest = "request" + ResourceAttempt = "attempt" +) + +// Short help (one line) for common commands. Use when the only difference is the resource name. +func ShortGet(resource string) string { return "Get " + resource + " details" } +func ShortList(resource string) string { return "List " + resource + "s" } +func ShortDelete(resource string) string { return "Delete a " + resource } +func ShortDisable(resource string) string { return "Disable a " + resource } +func ShortEnable(resource string) string { return "Enable a " + resource } +func ShortUpdate(resource string) string { return "Update a " + resource + " by ID" } +func ShortCreate(resource string) string { return "Create a new " + resource } +func ShortUpsert(resource string) string { return "Create or update a " + resource + " by name" } + +// LongGetIntro returns the first paragraph for "get" commands: "Get detailed information about a specific {resource}.\n\nYou can specify either a {resource} ID or name." +// Callers append their own Examples block. +func LongGetIntro(resource string) string { + return "Get detailed information about a specific " + resource + ".\n\nYou can specify either a " + resource + " ID or name." +} + +// LongUpdateIntro returns the first sentence for "update" commands. +func LongUpdateIntro(resource string) string { + return "Update an existing " + resource + " by its ID." +} + +// LongDeleteIntro returns the first sentence for "delete" commands. +func LongDeleteIntro(resource string) string { + return "Delete a " + resource + "." +} + +// LongDisableIntro returns the first sentence for "disable" commands. +func LongDisableIntro(resource string) string { + return "Disable an active " + resource + ". It will stop receiving new events until re-enabled." +} + +// LongEnableIntro returns the first sentence for "enable" commands. +func LongEnableIntro(resource string) string { + return "Enable a disabled " + resource + "." +} + +// LongUpsertIntro returns the first sentence for "upsert" commands (create or update by name). +func LongUpsertIntro(resource string) string { + return "Create a new " + resource + " or update an existing one by name (idempotent)." +} diff --git a/pkg/cmd/listen.go b/pkg/cmd/listen.go index 88a9505..e223691 100644 --- a/pkg/cmd/listen.go +++ b/pkg/cmd/listen.go @@ -103,7 +103,7 @@ func newListenCmd() *listenCmd { lc := &listenCmd{} lc.cmd = &cobra.Command{ - Use: "listen", + Use: "listen [port or forwarding URL] [source] [connection]", Short: "Forward events for a source to your local server", Long: `Forward events for a source to your local server. @@ -148,6 +148,13 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`, }, RunE: lc.runListenCmd, } + lc.cmd.Annotations = map[string]string{ + "cli.arguments": `[ + {"name":"port or forwarding URL","type":"string","description":"Port (e.g. 3000) or full URL (e.g. http://localhost:3000) to forward events to. The forward URL will be http://localhost:$PORT/$DESTINATION_PATH or http://domain/$DESTINATION_PATH. Only one of port or domain is required.","required":true}, + {"name":"source","type":"string","description":"The name of a source to listen to, a comma-separated list of source names, or '*' (with quotes) to listen to all. If empty, the CLI prompts you to choose.","required":false}, + {"name":"connection","type":"string","description":"Filter connections by connection name or path.","required":false} + ]`, + } lc.cmd.Flags().BoolVar(&lc.noWSS, "no-wss", false, "Force unencrypted ws:// protocol instead of wss://") lc.cmd.Flags().MarkHidden("no-wss") diff --git a/pkg/cmd/login.go b/pkg/cmd/login.go index 59664b8..7a72dfe 100644 --- a/pkg/cmd/login.go +++ b/pkg/cmd/login.go @@ -22,7 +22,9 @@ func newLoginCmd() *loginCmd { Args: validators.NoArgs, Short: "Login to your Hookdeck account", Long: `Login to your Hookdeck account to setup the CLI`, - RunE: lc.runLoginCmd, + Example: ` $ hookdeck login + $ hookdeck login -i # interactive mode (no browser)`, + RunE: lc.runLoginCmd, } lc.cmd.Flags().BoolVarP(&lc.interactive, "interactive", "i", false, "Run interactive configuration mode if you cannot open a browser") diff --git a/pkg/cmd/logout.go b/pkg/cmd/logout.go index f73ce4e..5399e5c 100644 --- a/pkg/cmd/logout.go +++ b/pkg/cmd/logout.go @@ -20,7 +20,9 @@ func newLogoutCmd() *logoutCmd { Args: validators.NoArgs, Short: "Logout of your Hookdeck account", Long: `Logout of your Hookdeck account to setup the CLI`, - RunE: lc.runLogoutCmd, + Example: ` $ hookdeck logout + $ hookdeck logout -a # clear all projects`, + RunE: lc.runLogoutCmd, } lc.cmd.Flags().BoolVarP(&lc.all, "all", "a", false, "Clear credentials for all projects you are currently logged into.") diff --git a/pkg/cmd/project.go b/pkg/cmd/project.go index e9ec260..898f2e3 100644 --- a/pkg/cmd/project.go +++ b/pkg/cmd/project.go @@ -18,6 +18,7 @@ func newProjectCmd() *projectCmd { Aliases: []string{"projects"}, Args: validators.NoArgs, Short: "Manage your projects", + Long: `If you are part of multiple projects, switch between them using project management commands. Projects were previously known as "Workspaces" in the Hookdeck dashboard; the CLI has been updated to use the project terminology.`, } lc.cmd.AddCommand(newProjectListCmd().cmd) diff --git a/pkg/cmd/project_list.go b/pkg/cmd/project_list.go index c58b7ca..3390266 100644 --- a/pkg/cmd/project_list.go +++ b/pkg/cmd/project_list.go @@ -21,10 +21,14 @@ func newProjectListCmd() *projectListCmd { lc := &projectListCmd{} lc.cmd = &cobra.Command{ - Use: "list [] []", - Args: validators.MaximumNArgs(2), - Short: "List and filter projects by organization and project name substrings", - RunE: lc.runProjectListCmd, + Use: "list [] []", + Args: validators.MaximumNArgs(2), + Short: "List and filter projects by organization and project name substrings", + RunE: lc.runProjectListCmd, + Example: `$ hookdeck project list +[Acme] Ecommerce Production (current) +[Acme] Ecommerce Staging +[Acme] Ecommerce Development`, } return lc diff --git a/pkg/cmd/project_use.go b/pkg/cmd/project_use.go index 881a7e4..614ede7 100644 --- a/pkg/cmd/project_use.go +++ b/pkg/cmd/project_use.go @@ -24,10 +24,21 @@ func newProjectUseCmd() *projectUseCmd { lc := &projectUseCmd{} lc.cmd = &cobra.Command{ - Use: "use [ []]", - Args: validators.MaximumNArgs(2), - Short: "Set the active project for future commands", - RunE: lc.runProjectUseCmd, + Use: "use [ []]", + Args: validators.MaximumNArgs(2), + Short: "Set the active project for future commands", + RunE: lc.runProjectUseCmd, + Example: `$ hookdeck project use +Use the arrow keys to navigate: ↓ ↑ β†’ ← +? Select Project: + β–Έ [Acme] Ecommerce Production + [Acme] Ecommerce Staging + [Acme] Ecommerce Development + +Selecting project [Acme] Ecommerce Staging + +$ hookdeck project use --local +Pinning project [Acme] Ecommerce Staging to current directory`, } lc.cmd.Flags().BoolVar(&lc.local, "local", false, "Save project to current directory (.hookdeck/config.toml)") diff --git a/pkg/cmd/request.go b/pkg/cmd/request.go new file mode 100644 index 0000000..a95a698 --- /dev/null +++ b/pkg/cmd/request.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestCmd struct { + cmd *cobra.Command +} + +func newRequestCmd() *requestCmd { + rc := &requestCmd{} + + rc.cmd = &cobra.Command{ + Use: "request", + Aliases: []string{"requests"}, + Args: validators.NoArgs, + Short: "Inspect and manage requests", + Long: `List, get, and retry requests (raw inbound webhooks). View events or ignored events for a request.`, + } + + rc.cmd.AddCommand(newRequestListCmd().cmd) + rc.cmd.AddCommand(newRequestGetCmd().cmd) + rc.cmd.AddCommand(newRequestRawBodyCmd().cmd) + rc.cmd.AddCommand(newRequestRetryCmd().cmd) + rc.cmd.AddCommand(newRequestEventsCmd().cmd) + rc.cmd.AddCommand(newRequestIgnoredEventsCmd().cmd) + + return rc +} + +func addRequestCmdTo(parent *cobra.Command) { + parent.AddCommand(newRequestCmd().cmd) +} diff --git a/pkg/cmd/request_events.go b/pkg/cmd/request_events.go new file mode 100644 index 0000000..37002d9 --- /dev/null +++ b/pkg/cmd/request_events.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestEventsCmd struct { + cmd *cobra.Command + limit int + next string + prev string + output string +} + +func newRequestEventsCmd() *requestEventsCmd { + rc := &requestEventsCmd{} + + rc.cmd = &cobra.Command{ + Use: "events ", + Args: validators.ExactArgs(1), + Short: "List events for a request", + Long: `List events (deliveries) created from a request. + +Examples: + hookdeck gateway request events req_abc123`, + RunE: rc.runRequestEventsCmd, + } + + rc.cmd.Flags().IntVar(&rc.limit, "limit", 100, "Limit number of results") + rc.cmd.Flags().StringVar(&rc.next, "next", "", "Pagination cursor for next page") + rc.cmd.Flags().StringVar(&rc.prev, "prev", "", "Pagination cursor for previous page") + rc.cmd.Flags().StringVar(&rc.output, "output", "", "Output format (json)") + + return rc +} + +func (rc *requestEventsCmd) runRequestEventsCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + requestID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + params := map[string]string{"limit": strconv.Itoa(rc.limit)} + if rc.next != "" { + params["next"] = rc.next + } + if rc.prev != "" { + params["prev"] = rc.prev + } + + resp, err := client.GetRequestEvents(ctx, requestID, params) + if err != nil { + return fmt.Errorf("failed to list request events: %w", err) + } + + if rc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal events to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No events found for this request.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, e := range resp.Models { + fmt.Printf("%s %s %s\n", color.Green(e.ID), e.Status, e.WebhookID) + } + return nil +} diff --git a/pkg/cmd/request_get.go b/pkg/cmd/request_get.go new file mode 100644 index 0000000..edf463b --- /dev/null +++ b/pkg/cmd/request_get.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestGetCmd struct { + cmd *cobra.Command + output string +} + +func newRequestGetCmd() *requestGetCmd { + rc := &requestGetCmd{} + + rc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceRequest), + Long: `Get detailed information about a request by ID. + +Examples: + hookdeck gateway request get req_abc123`, + RunE: rc.runRequestGetCmd, + } + + rc.cmd.Flags().StringVar(&rc.output, "output", "", "Output format (json)") + + return rc +} + +func (rc *requestGetCmd) runRequestGetCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + requestID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + req, err := client.GetRequest(ctx, requestID, nil) + if err != nil { + return fmt.Errorf("failed to get request: %w", err) + } + + if rc.output == "json" { + jsonBytes, err := json.MarshalIndent(req, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal request to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(req.ID)) + fmt.Printf(" Source ID: %s\n", req.SourceID) + fmt.Printf(" Verified: %v\n", req.Verified) + fmt.Printf(" Events count: %d\n", req.EventsCount) + fmt.Printf(" Ignored count: %d\n", req.IgnoredCount) + if req.RejectionCause != nil { + fmt.Printf(" Rejection: %s\n", *req.RejectionCause) + } + fmt.Printf(" Created: %s\n", req.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + return nil +} diff --git a/pkg/cmd/request_ignored_events.go b/pkg/cmd/request_ignored_events.go new file mode 100644 index 0000000..8343f15 --- /dev/null +++ b/pkg/cmd/request_ignored_events.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestIgnoredEventsCmd struct { + cmd *cobra.Command + limit int + next string + prev string + output string +} + +func newRequestIgnoredEventsCmd() *requestIgnoredEventsCmd { + rc := &requestIgnoredEventsCmd{} + + rc.cmd = &cobra.Command{ + Use: "ignored-events ", + Args: validators.ExactArgs(1), + Short: "List ignored events for a request", + Long: `List ignored events for a request (e.g. filtered out or deduplicated). + +Examples: + hookdeck gateway request ignored-events req_abc123`, + RunE: rc.runRequestIgnoredEventsCmd, + } + + rc.cmd.Flags().IntVar(&rc.limit, "limit", 100, "Limit number of results") + rc.cmd.Flags().StringVar(&rc.next, "next", "", "Pagination cursor for next page") + rc.cmd.Flags().StringVar(&rc.prev, "prev", "", "Pagination cursor for previous page") + rc.cmd.Flags().StringVar(&rc.output, "output", "", "Output format (json)") + + return rc +} + +func (rc *requestIgnoredEventsCmd) runRequestIgnoredEventsCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + requestID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + params := map[string]string{"limit": strconv.Itoa(rc.limit)} + if rc.next != "" { + params["next"] = rc.next + } + if rc.prev != "" { + params["prev"] = rc.prev + } + + resp, err := client.GetRequestIgnoredEvents(ctx, requestID, params) + if err != nil { + return fmt.Errorf("failed to list request ignored events: %w", err) + } + + if rc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal events to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No ignored events found for this request.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, e := range resp.Models { + fmt.Printf("%s %s %s\n", color.Green(e.ID), e.Status, e.WebhookID) + } + return nil +} diff --git a/pkg/cmd/request_list.go b/pkg/cmd/request_list.go new file mode 100644 index 0000000..32797ad --- /dev/null +++ b/pkg/cmd/request_list.go @@ -0,0 +1,166 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestListCmd struct { + cmd *cobra.Command + + id string + sourceID string + status string + verified string + rejectionCause string + createdAfter string + createdBefore string + ingestedAfter string + ingestedBefore string + headers string + body string + path string + parsedQuery string + orderBy string + dir string + limit int + next string + prev string + output string +} + +func newRequestListCmd() *requestListCmd { + rc := &requestListCmd{} + + rc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceRequest), + Long: `List requests (raw inbound webhooks). Filter by source ID. + +Examples: + hookdeck gateway request list + hookdeck gateway request list --source-id src_abc123 --limit 20`, + RunE: rc.runRequestListCmd, + } + + rc.cmd.Flags().StringVar(&rc.id, "id", "", "Filter by request ID(s) (comma-separated)") + rc.cmd.Flags().StringVar(&rc.sourceID, "source-id", "", "Filter by source ID") + rc.cmd.Flags().StringVar(&rc.status, "status", "", "Filter by status") + rc.cmd.Flags().StringVar(&rc.verified, "verified", "", "Filter by verified (true/false)") + rc.cmd.Flags().StringVar(&rc.rejectionCause, "rejection-cause", "", "Filter by rejection cause") + rc.cmd.Flags().StringVar(&rc.createdAfter, "created-after", "", "Filter requests created after (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.createdBefore, "created-before", "", "Filter requests created before (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.ingestedAfter, "ingested-at-after", "", "Filter by ingested_at after (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.ingestedBefore, "ingested-at-before", "", "Filter by ingested_at before (ISO date-time)") + rc.cmd.Flags().StringVar(&rc.headers, "headers", "", "Filter by headers (JSON string)") + rc.cmd.Flags().StringVar(&rc.body, "body", "", "Filter by body (JSON string)") + rc.cmd.Flags().StringVar(&rc.path, "path", "", "Filter by path") + rc.cmd.Flags().StringVar(&rc.parsedQuery, "parsed-query", "", "Filter by parsed query (JSON string)") + rc.cmd.Flags().StringVar(&rc.orderBy, "order-by", "", "Sort key (e.g. created_at)") + rc.cmd.Flags().StringVar(&rc.dir, "dir", "", "Sort direction (asc, desc)") + rc.cmd.Flags().IntVar(&rc.limit, "limit", 100, "Limit number of results") + rc.cmd.Flags().StringVar(&rc.next, "next", "", "Pagination cursor for next page") + rc.cmd.Flags().StringVar(&rc.prev, "prev", "", "Pagination cursor for previous page") + rc.cmd.Flags().StringVar(&rc.output, "output", "", "Output format (json)") + + return rc +} + +func (rc *requestListCmd) runRequestListCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + if rc.id != "" { + params["id"] = rc.id + } + if rc.sourceID != "" { + params["source_id"] = rc.sourceID + } + if rc.status != "" { + params["status"] = rc.status + } + if rc.verified != "" { + params["verified"] = rc.verified + } + if rc.rejectionCause != "" { + params["rejection_cause"] = rc.rejectionCause + } + if rc.createdAfter != "" { + params["created_at[gte]"] = rc.createdAfter + } + if rc.createdBefore != "" { + params["created_at[lte]"] = rc.createdBefore + } + if rc.ingestedAfter != "" { + params["ingested_at[gte]"] = rc.ingestedAfter + } + if rc.ingestedBefore != "" { + params["ingested_at[lte]"] = rc.ingestedBefore + } + if rc.headers != "" { + params["headers"] = rc.headers + } + if rc.body != "" { + params["body"] = rc.body + } + if rc.path != "" { + params["path"] = rc.path + } + if rc.parsedQuery != "" { + params["parsed_query"] = rc.parsedQuery + } + if rc.orderBy != "" { + params["order_by"] = rc.orderBy + } + if rc.dir != "" { + params["dir"] = rc.dir + } + params["limit"] = strconv.Itoa(rc.limit) + if rc.next != "" { + params["next"] = rc.next + } + if rc.prev != "" { + params["prev"] = rc.prev + } + + resp, err := client.ListRequests(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list requests: %w", err) + } + + if rc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal requests to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No requests found.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, r := range resp.Models { + fmt.Printf("%s %s (events: %d)\n", color.Green(r.ID), r.SourceID, r.EventsCount) + } + return nil +} diff --git a/pkg/cmd/request_raw_body.go b/pkg/cmd/request_raw_body.go new file mode 100644 index 0000000..3ca4d84 --- /dev/null +++ b/pkg/cmd/request_raw_body.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestRawBodyCmd struct { + cmd *cobra.Command +} + +func newRequestRawBodyCmd() *requestRawBodyCmd { + rc := &requestRawBodyCmd{} + + rc.cmd = &cobra.Command{ + Use: "raw-body ", + Args: validators.ExactArgs(1), + Short: "Get raw body of a request", + Long: `Output the raw request body of a request by ID. + +Examples: + hookdeck gateway request raw-body req_abc123`, + RunE: rc.runRequestRawBodyCmd, + } + + return rc +} + +func (rc *requestRawBodyCmd) runRequestRawBodyCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + requestID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + body, err := client.GetRequestRawBody(ctx, requestID) + if err != nil { + return fmt.Errorf("failed to get request raw body: %w", err) + } + _, _ = os.Stdout.Write(body) + return nil +} diff --git a/pkg/cmd/request_retry.go b/pkg/cmd/request_retry.go new file mode 100644 index 0000000..dff80a6 --- /dev/null +++ b/pkg/cmd/request_retry.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type requestRetryCmd struct { + cmd *cobra.Command + connectionIDs string +} + +func newRequestRetryCmd() *requestRetryCmd { + rc := &requestRetryCmd{} + + rc.cmd = &cobra.Command{ + Use: "retry ", + Args: validators.ExactArgs(1), + Short: "Retry a request", + Long: `Retry a request by ID. By default retries on all connections. Use --connection-ids to retry only for specific connections. + +Examples: + hookdeck gateway request retry req_abc123 + hookdeck gateway request retry req_abc123 --connection-ids web_1,web_2`, + RunE: rc.runRequestRetryCmd, + } + + rc.cmd.Flags().StringVar(&rc.connectionIDs, "connection-ids", "", "Comma-separated connection IDs to retry (omit to retry all)") + + return rc +} + +func (rc *requestRetryCmd) runRequestRetryCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + requestID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + body := &hookdeck.RequestRetryRequest{} + if rc.connectionIDs != "" { + body.WebhookIDs = strings.Split(rc.connectionIDs, ",") + for i, id := range body.WebhookIDs { + body.WebhookIDs[i] = strings.TrimSpace(id) + } + } + + if err := client.RetryRequest(ctx, requestID, body); err != nil { + return fmt.Errorf("failed to retry request: %w", err) + } + fmt.Printf("Request %s retry requested.\n", requestID) + return nil +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 26018f7..4c3b9ee 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -38,6 +38,19 @@ var rootCmd = &cobra.Command{ Short: "A CLI to forward events received on Hookdeck to your local server.", } +// RootCmd returns the root command for use by tools (e.g. generate-reference). +func RootCmd() *cobra.Command { + return rootCmd +} + +// addConnectionCmdTo registers the connection command tree on a parent so that +// "connection" (and alias "connections") is available there. Call twice to expose +// the same subcommands under both gateway and root (backward compat). +// Command definitions live only in newConnectionCmd(); this just registers the result. +func addConnectionCmdTo(parent *cobra.Command) { + parent.AddCommand(newConnectionCmd().cmd) +} + // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { @@ -127,5 +140,7 @@ func init() { rootCmd.AddCommand(newCompletionCmd().cmd) rootCmd.AddCommand(newWhoamiCmd().cmd) rootCmd.AddCommand(newProjectCmd().cmd) - rootCmd.AddCommand(newConnectionCmd().cmd) + rootCmd.AddCommand(newGatewayCmd().cmd) + // Backward compat: same connection command tree also at root (single definition in newConnectionCmd) + addConnectionCmdTo(rootCmd) } diff --git a/pkg/cmd/source.go b/pkg/cmd/source.go new file mode 100644 index 0000000..47e7b9e --- /dev/null +++ b/pkg/cmd/source.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceCmd struct { + cmd *cobra.Command +} + +func newSourceCmd() *sourceCmd { + sc := &sourceCmd{} + + sc.cmd = &cobra.Command{ + Use: "source", + Aliases: []string{"sources"}, + Args: validators.NoArgs, + Short: "Manage your sources", + Long: `Manage webhook and event sources. + +Sources receive incoming webhooks and events. Create sources with a type (e.g. WEBHOOK, STRIPE) +and optional authentication config, then connect them to destinations via connections.`, + } + + sc.cmd.AddCommand(newSourceListCmd().cmd) + sc.cmd.AddCommand(newSourceGetCmd().cmd) + sc.cmd.AddCommand(newSourceCreateCmd().cmd) + sc.cmd.AddCommand(newSourceUpsertCmd().cmd) + sc.cmd.AddCommand(newSourceUpdateCmd().cmd) + sc.cmd.AddCommand(newSourceDeleteCmd().cmd) + sc.cmd.AddCommand(newSourceEnableCmd().cmd) + sc.cmd.AddCommand(newSourceDisableCmd().cmd) + sc.cmd.AddCommand(newSourceCountCmd().cmd) + + return sc +} + +// addSourceCmdTo registers the source command tree on the given parent (e.g. gateway or root). +func addSourceCmdTo(parent *cobra.Command) { + parent.AddCommand(newSourceCmd().cmd) +} diff --git a/pkg/cmd/source_common.go b/pkg/cmd/source_common.go new file mode 100644 index 0000000..c70668d --- /dev/null +++ b/pkg/cmd/source_common.go @@ -0,0 +1,291 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/hookdeck/hookdeck-cli/pkg/cmd/sources" +) + +// sourceConfigFlags holds individual source config flags (no "source-" prefix). +// Used by source create, upsert, and update. Same semantics as connection's +// --source-* flags; when both --config/--config-file and individual flags are +// set, --config/--config-file take precedence. +type sourceConfigFlags struct { + WebhookSecret string + APIKey string + BasicAuthUser string + BasicAuthPass string + HMACSecret string + HMACAlgo string + AllowedHTTPMethods string + CustomResponseBody string + CustomResponseType string +} + +// hasAny returns true if any individual config flag is set. +func (f *sourceConfigFlags) hasAny() bool { + if f == nil { + return false + } + return f.WebhookSecret != "" || f.APIKey != "" || + f.BasicAuthUser != "" || f.BasicAuthPass != "" || + f.HMACSecret != "" || f.HMACAlgo != "" || + f.AllowedHTTPMethods != "" || f.CustomResponseBody != "" || f.CustomResponseType != "" +} + +// flagRef returns the flag string for error messages (e.g. "" -> "--allowed-http-methods", "source-" -> "--source-allowed-http-methods"). +func flagRef(prefix, name string) string { + return "--" + prefix + name +} + +// buildSourceConfigFromIndividualFlags builds source config from individual flags. +// Source-level type (WEBHOOK, STRIPE, etc.) determines which config schema applies; config.auth +// contents depend on that type (per OpenAPI SourceTypeConfig oneOf). No auth_type fieldβ€”only auth. +// Shared by source create/upsert/update (prefix "") and connection create/upsert (prefix "source-"). +// flagPrefix is used only in error messages so connection errors mention --source-*. +func buildSourceConfigFromIndividualFlags(f *sourceConfigFlags, flagPrefix, sourceType string) (map[string]interface{}, error) { + if f == nil || !f.hasAny() { + return nil, nil + } + config := make(map[string]interface{}) + sourceTypeUpper := strings.ToUpper(strings.TrimSpace(sourceType)) + + // Auth: only config.auth; shape depends on source type (API infers from type + auth keys). + if f.WebhookSecret != "" { + if sourceTypeUpper == "STRIPE" { + config["auth"] = map[string]interface{}{"webhook_secret_key": f.WebhookSecret} + } else { + config["auth"] = map[string]interface{}{ + "algorithm": "sha256", + "encoding": "hex", + "header_key": "x-webhook-signature", + "webhook_secret_key": f.WebhookSecret, + } + } + } else if f.HMACSecret != "" { + algo := "sha256" + if f.HMACAlgo != "" { + algo = strings.ToLower(f.HMACAlgo) + } + config["auth"] = map[string]interface{}{ + "algorithm": algo, + "encoding": "hex", + "header_key": "x-webhook-signature", + "webhook_secret_key": f.HMACSecret, + } + } else if f.APIKey != "" { + config["auth"] = map[string]interface{}{ + "header_key": "x-api-key", + "api_key": f.APIKey, + } + } else if f.BasicAuthUser != "" || f.BasicAuthPass != "" { + config["auth"] = map[string]interface{}{ + "username": f.BasicAuthUser, + "password": f.BasicAuthPass, + } + } + + if f.AllowedHTTPMethods != "" { + methods := strings.Split(f.AllowedHTTPMethods, ",") + validMethods := []string{} + allowed := map[string]bool{"GET": true, "POST": true, "PUT": true, "PATCH": true, "DELETE": true} + for _, method := range methods { + method = strings.TrimSpace(strings.ToUpper(method)) + if !allowed[method] { + return nil, fmt.Errorf("invalid HTTP method %q in %s (allowed: GET, POST, PUT, PATCH, DELETE)", method, flagRef(flagPrefix, "allowed-http-methods")) + } + validMethods = append(validMethods, method) + } + config["allowed_http_methods"] = validMethods + } + if f.CustomResponseType != "" || f.CustomResponseBody != "" { + if f.CustomResponseType == "" { + return nil, fmt.Errorf("%s is required when using %s", flagRef(flagPrefix, "custom-response-content-type"), flagRef(flagPrefix, "custom-response-body")) + } + if f.CustomResponseBody == "" { + return nil, fmt.Errorf("%s is required when using %s", flagRef(flagPrefix, "custom-response-body"), flagRef(flagPrefix, "custom-response-content-type")) + } + validTypes := map[string]bool{"json": true, "text": true, "xml": true} + contentType := strings.ToLower(f.CustomResponseType) + if !validTypes[contentType] { + return nil, fmt.Errorf("invalid content type %q in %s (allowed: json, text, xml)", f.CustomResponseType, flagRef(flagPrefix, "custom-response-content-type")) + } + if len(f.CustomResponseBody) > 1000 { + return nil, fmt.Errorf("%s exceeds maximum length of 1000 characters (got %d)", flagRef(flagPrefix, "custom-response-body"), len(f.CustomResponseBody)) + } + config["custom_response"] = map[string]interface{}{ + "content_type": contentType, + "body": f.CustomResponseBody, + } + } + return config, nil +} + +// ensureSourceConfigAuthTypeForHTTP sets config.auth_type when source type is HTTP and config +// has auth. The connection API requires auth_type in config for HTTP sources. Values: API_KEY, +// BASIC_AUTH, HMAC. No-op if auth_type already set or source type is not HTTP. +func ensureSourceConfigAuthTypeForHTTP(config map[string]interface{}, sourceType string) { + if config == nil || strings.ToUpper(strings.TrimSpace(sourceType)) != "HTTP" { + return + } + if _, hasAuth := config["auth"]; !hasAuth { + return + } + if _, hasType := config["auth_type"]; hasType { + return + } + auth, _ := config["auth"].(map[string]interface{}) + if auth == nil { + return + } + if _, ok := auth["api_key"]; ok { + config["auth_type"] = "API_KEY" + return + } + if _, ok := auth["username"]; ok { + config["auth_type"] = "BASIC_AUTH" + return + } + if _, ok := auth["webhook_secret_key"]; ok { + config["auth_type"] = "HMAC" + } +} + +// normalizeSourceConfigAuth converts legacy flat auth keys (webhook_secret, api_key, etc.) +// into the API shape: config.auth only (no auth_type; type is source-level, auth shape depends on it). +// Idempotent if auth already set. +func normalizeSourceConfigAuth(config map[string]interface{}, sourceType string) { + if config == nil || config["auth"] != nil { + return + } + sourceTypeUpper := strings.ToUpper(strings.TrimSpace(sourceType)) + if v, ok := config["webhook_secret"].(string); ok && v != "" { + if sourceTypeUpper == "STRIPE" { + config["auth"] = map[string]interface{}{"webhook_secret_key": v} + } else { + config["auth"] = map[string]interface{}{ + "algorithm": "sha256", "encoding": "hex", + "header_key": "x-webhook-signature", "webhook_secret_key": v, + } + } + delete(config, "webhook_secret") + return + } + if v, ok := config["api_key"].(string); ok && v != "" { + config["auth"] = map[string]interface{}{"header_key": "x-api-key", "api_key": v} + delete(config, "api_key") + return + } + if m, ok := config["basic_auth"].(map[string]interface{}); ok { + u, _ := m["username"].(string) + p, _ := m["password"].(string) + if u != "" || p != "" { + config["auth"] = map[string]interface{}{"username": u, "password": p} + delete(config, "basic_auth") + } + return + } + if m, ok := config["hmac"].(map[string]interface{}); ok { + secret, _ := m["secret"].(string) + if secret != "" { + algo := "sha256" + if a, _ := m["algorithm"].(string); a != "" { + algo = strings.ToLower(a) + } + config["auth"] = map[string]interface{}{ + "algorithm": algo, "encoding": "hex", + "header_key": "x-webhook-signature", "webhook_secret_key": secret, + } + delete(config, "hmac") + } + } +} + +// buildSourceConfigFromFlags parses source config from --config/--config-file (JSON) +// or from individual flags. sourceType (e.g. WEBHOOK, STRIPE) is used for correct auth shape. +// When configStr or configFile is set, that takes precedence. +// Used by source create, upsert, and update. Returns (nil, nil) when nothing is set. +// Normalizes legacy flat auth keys to auth_type + auth so the API accepts the payload. +func buildSourceConfigFromFlags(configStr, configFile string, individual *sourceConfigFlags, sourceType string) (map[string]interface{}, error) { + if configStr != "" { + var out map[string]interface{} + if err := json.Unmarshal([]byte(configStr), &out); err != nil { + return nil, fmt.Errorf("invalid JSON in --config: %w", err) + } + normalizeSourceConfigAuth(out, sourceType) + return out, nil + } + if configFile != "" { + data, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to read --config-file: %w", err) + } + var out map[string]interface{} + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("invalid JSON in config file: %w", err) + } + normalizeSourceConfigAuth(out, sourceType) + return out, nil + } + return buildSourceConfigFromIndividualFlags(individual, "", sourceType) +} + +// sourceAuthFlags holds the auth-related flag values for spec-based validation. +// Used by source create/upsert (unprefixed) and connection create (--source-* prefixed). +type sourceAuthFlags struct { + WebhookSecret string + APIKey string + BasicAuthUser string + BasicAuthPass string + HMACSecret string +} + +// optionalAuthSourceTypes are source types where authentication can be turned on or off +// but is not required. We do not reject these when auth flags are missing. +var optionalAuthSourceTypes = map[string]bool{"STRIPE": true} + +// validateSourceAuthFromSpec uses the cached OpenAPI spec (FetchSourceTypes) to validate +// that the given source type has the required auth flags set. Used by source create/upsert +// (flagPrefix "") and connection create (flagPrefix "source-"). If configSet, skip validation. +// Types in optionalAuthSourceTypes are not required to have auth set. If FetchSourceTypes +// fails or the type is unknown, returns nil so the API can validate. +func validateSourceAuthFromSpec(sourceType string, configSet bool, auth sourceAuthFlags, flagPrefix string) error { + if sourceType == "" || configSet { + return nil + } + if optionalAuthSourceTypes[strings.ToUpper(sourceType)] { + return nil + } + sourceTypes, err := sources.FetchSourceTypes() + if err != nil { + fmt.Printf("Warning: could not fetch source types for validation: %v\n", err) + return nil + } + st, ok := sourceTypes[strings.ToUpper(sourceType)] + if !ok { + return nil + } + pre := "--" + flagPrefix + switch st.AuthScheme { + case "webhook_secret": + if auth.WebhookSecret == "" { + return fmt.Errorf("%swebhook-secret is required for source type %s", pre, sourceType) + } + case "api_key": + if auth.APIKey == "" { + return fmt.Errorf("%sapi-key is required for source type %s", pre, sourceType) + } + case "basic_auth": + if auth.BasicAuthUser == "" || auth.BasicAuthPass == "" { + return fmt.Errorf("%sbasic-auth-user and %sbasic-auth-pass are required for source type %s", pre, pre, sourceType) + } + case "hmac": + if auth.HMACSecret == "" { + return fmt.Errorf("%shmac-secret is required for source type %s", pre, sourceType) + } + } + return nil +} diff --git a/pkg/cmd/source_count.go b/pkg/cmd/source_count.go new file mode 100644 index 0000000..e245e5e --- /dev/null +++ b/pkg/cmd/source_count.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceCountCmd struct { + cmd *cobra.Command + + name string + sourceType string + disabled bool +} + +func newSourceCountCmd() *sourceCountCmd { + sc := &sourceCountCmd{} + + sc.cmd = &cobra.Command{ + Use: "count", + Args: validators.NoArgs, + Short: "Count sources", + Long: `Count sources matching optional filters. + +Examples: + hookdeck gateway source count + hookdeck gateway source count --type WEBHOOK + hookdeck gateway source count --disabled`, + RunE: sc.runSourceCountCmd, + } + + sc.cmd.Flags().StringVar(&sc.name, "name", "", "Filter by source name") + sc.cmd.Flags().StringVar(&sc.sourceType, "type", "", "Filter by source type") + sc.cmd.Flags().BoolVar(&sc.disabled, "disabled", false, "Count disabled sources only (when set with other filters)") + + return sc +} + +func (sc *sourceCountCmd) runSourceCountCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + if sc.name != "" { + params["name"] = sc.name + } + if sc.sourceType != "" { + params["type"] = sc.sourceType + } + if sc.disabled { + params["disabled"] = "true" + } else { + params["disabled"] = "false" + } + + resp, err := client.CountSources(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to count sources: %w", err) + } + + fmt.Println(strconv.Itoa(resp.Count)) + return nil +} diff --git a/pkg/cmd/source_create.go b/pkg/cmd/source_create.go new file mode 100644 index 0000000..9460a5b --- /dev/null +++ b/pkg/cmd/source_create.go @@ -0,0 +1,127 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceCreateCmd struct { + cmd *cobra.Command + + name string + description string + sourceType string + config string + configFile string + output string + + sourceConfigFlags +} + +func newSourceCreateCmd() *sourceCreateCmd { + sc := &sourceCreateCmd{} + + sc.cmd = &cobra.Command{ + Use: "create", + Args: validators.NoArgs, + Short: ShortCreate(ResourceSource), + Long: `Create a new source. + +Requires --name and --type. Use --config or --config-file for authentication (e.g. webhook_secret, api_key). + +Examples: + hookdeck gateway source create --name my-webhook --type WEBHOOK + hookdeck gateway source create --name stripe-prod --type STRIPE --config '{"webhook_secret":"whsec_xxx"}'`, + PreRunE: sc.validateFlags, + RunE: sc.runSourceCreateCmd, + } + + sc.cmd.Flags().StringVar(&sc.name, "name", "", "Source name (required)") + sc.cmd.Flags().StringVar(&sc.description, "description", "", "Source description") + sc.cmd.Flags().StringVar(&sc.sourceType, "type", "", "Source type (e.g. WEBHOOK, STRIPE) (required)") + sc.cmd.Flags().StringVar(&sc.config, "config", "", "JSON object for source config (overrides individual flags if set)") + sc.cmd.Flags().StringVar(&sc.configFile, "config-file", "", "Path to JSON file for source config (overrides individual flags if set)") + sc.cmd.Flags().StringVar(&sc.WebhookSecret, "webhook-secret", "", "Webhook secret for source verification (e.g., Stripe)") + sc.cmd.Flags().StringVar(&sc.APIKey, "api-key", "", "API key for source authentication") + sc.cmd.Flags().StringVar(&sc.BasicAuthUser, "basic-auth-user", "", "Username for Basic authentication") + sc.cmd.Flags().StringVar(&sc.BasicAuthPass, "basic-auth-pass", "", "Password for Basic authentication") + sc.cmd.Flags().StringVar(&sc.HMACSecret, "hmac-secret", "", "HMAC secret for signature verification") + sc.cmd.Flags().StringVar(&sc.HMACAlgo, "hmac-algo", "", "HMAC algorithm (SHA256, etc.)") + sc.cmd.Flags().StringVar(&sc.AllowedHTTPMethods, "allowed-http-methods", "", "Comma-separated allowed HTTP methods (GET, POST, PUT, PATCH, DELETE)") + sc.cmd.Flags().StringVar(&sc.CustomResponseBody, "custom-response-body", "", "Custom response body (max 1000 chars)") + sc.cmd.Flags().StringVar(&sc.CustomResponseType, "custom-response-content-type", "", "Custom response content type (json, text, xml)") + sc.cmd.Flags().StringVar(&sc.output, "output", "", "Output format (json)") + + sc.cmd.MarkFlagRequired("name") + sc.cmd.MarkFlagRequired("type") + + return sc +} + +func (sc *sourceCreateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if sc.config != "" && sc.configFile != "" { + return fmt.Errorf("cannot use both --config and --config-file") + } + auth := sourceAuthFlags{ + WebhookSecret: sc.WebhookSecret, + APIKey: sc.APIKey, + BasicAuthUser: sc.BasicAuthUser, + BasicAuthPass: sc.BasicAuthPass, + HMACSecret: sc.HMACSecret, + } + return validateSourceAuthFromSpec(sc.sourceType, sc.config != "" || sc.configFile != "", auth, "") +} + +func (sc *sourceCreateCmd) runSourceCreateCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + config, err := buildSourceConfigFromFlags(sc.config, sc.configFile, &sc.sourceConfigFlags, sc.sourceType) + if err != nil { + return err + } + if config != nil { + ensureSourceConfigAuthTypeForHTTP(config, sc.sourceType) + } + + req := &hookdeck.SourceCreateRequest{ + Name: sc.name, + Type: strings.ToUpper(sc.sourceType), + } + if sc.description != "" { + req.Description = &sc.description + } + if len(config) > 0 { + req.Config = config + } + + src, err := client.CreateSource(ctx, req) + if err != nil { + return fmt.Errorf("failed to create source: %w", err) + } + + if sc.output == "json" { + jsonBytes, err := json.MarshalIndent(src, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal source to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Source created successfully\n\n") + fmt.Printf("Source: %s (%s)\n", src.Name, src.ID) + fmt.Printf("Type: %s\n", src.Type) + fmt.Printf("URL: %s\n", src.URL) + return nil +} diff --git a/pkg/cmd/source_create_update_test.go b/pkg/cmd/source_create_update_test.go new file mode 100644 index 0000000..61059ab --- /dev/null +++ b/pkg/cmd/source_create_update_test.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSourceCreateRequiresName asserts that create without --name fails (Cobra required-flag validation). +func TestSourceCreateRequiresName(t *testing.T) { + rootCmd.SetArgs([]string{"gateway", "source", "create", "--type", "WEBHOOK"}) + err := rootCmd.Execute() + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "name") || strings.Contains(err.Error(), "required"), + "error should mention name or required, got: %s", err.Error()) +} + +// TestSourceUpdateRequestEmpty asserts the "no updates specified" logic for update. +func TestSourceUpdateRequestEmpty(t *testing.T) { + t.Run("empty request is empty", func(t *testing.T) { + req := &hookdeck.SourceUpdateRequest{} + assert.True(t, sourceUpdateRequestEmpty(req)) + }) + t.Run("name set is not empty", func(t *testing.T) { + req := &hookdeck.SourceUpdateRequest{Name: "x"} + assert.False(t, sourceUpdateRequestEmpty(req)) + }) + t.Run("config set is not empty", func(t *testing.T) { + req := &hookdeck.SourceUpdateRequest{Config: map[string]interface{}{"webhook_secret": "x"}} + assert.False(t, sourceUpdateRequestEmpty(req)) + }) + t.Run("type set is not empty", func(t *testing.T) { + req := &hookdeck.SourceUpdateRequest{Type: "WEBHOOK"} + assert.False(t, sourceUpdateRequestEmpty(req)) + }) + t.Run("description set is not empty", func(t *testing.T) { + s := "desc" + req := &hookdeck.SourceUpdateRequest{Description: &s} + assert.False(t, sourceUpdateRequestEmpty(req)) + }) +} diff --git a/pkg/cmd/source_delete.go b/pkg/cmd/source_delete.go new file mode 100644 index 0000000..4280a01 --- /dev/null +++ b/pkg/cmd/source_delete.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceDeleteCmd struct { + cmd *cobra.Command + force bool +} + +func newSourceDeleteCmd() *sourceDeleteCmd { + sc := &sourceDeleteCmd{} + + sc.cmd = &cobra.Command{ + Use: "delete ", + Args: validators.ExactArgs(1), + Short: ShortDelete(ResourceSource), + Long: LongDeleteIntro(ResourceSource) + ` + +Examples: + hookdeck gateway source delete src_abc123 + hookdeck gateway source delete src_abc123 --force`, + PreRunE: sc.validateFlags, + RunE: sc.runSourceDeleteCmd, + } + + sc.cmd.Flags().BoolVar(&sc.force, "force", false, "Force delete without confirmation") + + return sc +} + +func (sc *sourceDeleteCmd) validateFlags(cmd *cobra.Command, args []string) error { + return Config.Profile.ValidateAPIKey() +} + +func (sc *sourceDeleteCmd) runSourceDeleteCmd(cmd *cobra.Command, args []string) error { + sourceID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + src, err := client.GetSource(ctx, sourceID, nil) + if err != nil { + return fmt.Errorf("failed to get source: %w", err) + } + + if !sc.force { + fmt.Printf("\nAre you sure you want to delete source '%s' (%s)? [y/N]: ", src.Name, sourceID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" { + fmt.Println("Deletion cancelled.") + return nil + } + } + + if err := client.DeleteSource(ctx, sourceID); err != nil { + return fmt.Errorf("failed to delete source: %w", err) + } + + fmt.Printf("βœ” Source deleted: %s (%s)\n", src.Name, sourceID) + return nil +} diff --git a/pkg/cmd/source_disable.go b/pkg/cmd/source_disable.go new file mode 100644 index 0000000..6bd8501 --- /dev/null +++ b/pkg/cmd/source_disable.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceDisableCmd struct { + cmd *cobra.Command +} + +func newSourceDisableCmd() *sourceDisableCmd { + sc := &sourceDisableCmd{} + + sc.cmd = &cobra.Command{ + Use: "disable ", + Args: validators.ExactArgs(1), + Short: ShortDisable(ResourceSource), + Long: LongDisableIntro(ResourceSource), + RunE: sc.runSourceDisableCmd, + } + + return sc +} + +func (sc *sourceDisableCmd) runSourceDisableCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + ctx := context.Background() + + src, err := client.DisableSource(ctx, args[0]) + if err != nil { + return fmt.Errorf("failed to disable source: %w", err) + } + + fmt.Printf("βœ“ Source disabled: %s (%s)\n", src.Name, src.ID) + return nil +} diff --git a/pkg/cmd/source_enable.go b/pkg/cmd/source_enable.go new file mode 100644 index 0000000..520fd4d --- /dev/null +++ b/pkg/cmd/source_enable.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceEnableCmd struct { + cmd *cobra.Command +} + +func newSourceEnableCmd() *sourceEnableCmd { + sc := &sourceEnableCmd{} + + sc.cmd = &cobra.Command{ + Use: "enable ", + Args: validators.ExactArgs(1), + Short: ShortEnable(ResourceSource), + Long: LongEnableIntro(ResourceSource), + RunE: sc.runSourceEnableCmd, + } + + return sc +} + +func (sc *sourceEnableCmd) runSourceEnableCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + ctx := context.Background() + + src, err := client.EnableSource(ctx, args[0]) + if err != nil { + return fmt.Errorf("failed to enable source: %w", err) + } + + fmt.Printf("βœ“ Source enabled: %s (%s)\n", src.Name, src.ID) + return nil +} diff --git a/pkg/cmd/source_get.go b/pkg/cmd/source_get.go new file mode 100644 index 0000000..8cff8d9 --- /dev/null +++ b/pkg/cmd/source_get.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceGetCmd struct { + cmd *cobra.Command + output string + includeAuth bool +} + +func newSourceGetCmd() *sourceGetCmd { + sc := &sourceGetCmd{} + + sc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceSource), + Long: LongGetIntro(ResourceSource) + ` + +Examples: + hookdeck gateway source get src_abc123 + hookdeck gateway source get my-source --include-auth`, + RunE: sc.runSourceGetCmd, + } + + sc.cmd.Flags().StringVar(&sc.output, "output", "", "Output format (json)") + addIncludeSourceAuthFlag(sc.cmd, &sc.includeAuth) + + return sc +} + +func (sc *sourceGetCmd) runSourceGetCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + idOrName := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + sourceID, err := resolveSourceID(ctx, client, idOrName) + if err != nil { + return err + } + + params := includeAuthParams(sc.includeAuth) + + src, err := client.GetSource(ctx, sourceID, params) + if err != nil { + return fmt.Errorf("failed to get source: %w", err) + } + + if sc.output == "json" { + jsonBytes, err := json.MarshalIndent(src, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal source to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(src.Name)) + fmt.Printf(" ID: %s\n", src.ID) + fmt.Printf(" Type: %s\n", src.Type) + fmt.Printf(" URL: %s\n", src.URL) + if src.Description != nil && *src.Description != "" { + fmt.Printf(" Description: %s\n", *src.Description) + } + if src.DisabledAt != nil { + fmt.Printf(" Status: %s\n", color.Red("disabled")) + } else { + fmt.Printf(" Status: %s\n", color.Green("active")) + } + fmt.Printf(" Created: %s\n", src.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Updated: %s\n", src.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + + return nil +} + +// resolveSourceID returns the source ID for the given name or ID. +func resolveSourceID(ctx context.Context, client *hookdeck.Client, nameOrID string) (string, error) { + if strings.HasPrefix(nameOrID, "src_") { + _, err := client.GetSource(ctx, nameOrID, nil) + if err == nil { + return nameOrID, nil + } + errMsg := strings.ToLower(err.Error()) + if !strings.Contains(errMsg, "404") && !strings.Contains(errMsg, "not found") { + return "", err + } + } + + params := map[string]string{"name": nameOrID} + result, err := client.ListSources(ctx, params) + if err != nil { + return "", fmt.Errorf("failed to lookup source by name '%s': %w", nameOrID, err) + } + if result.Models == nil || len(result.Models) == 0 { + return "", fmt.Errorf("no source found with name or ID '%s'", nameOrID) + } + return result.Models[0].ID, nil +} diff --git a/pkg/cmd/source_list.go b/pkg/cmd/source_list.go new file mode 100644 index 0000000..d4bd34e --- /dev/null +++ b/pkg/cmd/source_list.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceListCmd struct { + cmd *cobra.Command + + name string + sourceType string + disabled bool + limit int + output string +} + +func newSourceListCmd() *sourceListCmd { + sc := &sourceListCmd{} + + sc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceSource), + Long: `List all sources or filter by name or type. + +Examples: + hookdeck gateway source list + hookdeck gateway source list --name my-source + hookdeck gateway source list --type WEBHOOK + hookdeck gateway source list --disabled + hookdeck gateway source list --limit 10`, + RunE: sc.runSourceListCmd, + } + + sc.cmd.Flags().StringVar(&sc.name, "name", "", "Filter by source name") + sc.cmd.Flags().StringVar(&sc.sourceType, "type", "", "Filter by source type (e.g. WEBHOOK, STRIPE)") + sc.cmd.Flags().BoolVar(&sc.disabled, "disabled", false, "Include disabled sources") + sc.cmd.Flags().IntVar(&sc.limit, "limit", 100, "Limit number of results") + sc.cmd.Flags().StringVar(&sc.output, "output", "", "Output format (json)") + + return sc +} + +func (sc *sourceListCmd) runSourceListCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + + if sc.name != "" { + params["name"] = sc.name + } + if sc.sourceType != "" { + params["type"] = sc.sourceType + } + if sc.disabled { + params["disabled"] = "true" + } else { + params["disabled"] = "false" + } + params["limit"] = strconv.Itoa(sc.limit) + + resp, err := client.ListSources(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list sources: %w", err) + } + + if sc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal sources to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No sources found.") + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\nFound %d source(s):\n\n", len(resp.Models)) + for _, src := range resp.Models { + fmt.Printf("%s\n", color.Green(src.Name)) + fmt.Printf(" ID: %s\n", src.ID) + fmt.Printf(" Type: %s\n", src.Type) + fmt.Printf(" URL: %s\n", src.URL) + if src.DisabledAt != nil { + fmt.Printf(" Status: %s\n", color.Red("disabled")) + } else { + fmt.Printf(" Status: %s\n", color.Green("active")) + } + fmt.Println() + } + + return nil +} diff --git a/pkg/cmd/source_update.go b/pkg/cmd/source_update.go new file mode 100644 index 0000000..d71bb37 --- /dev/null +++ b/pkg/cmd/source_update.go @@ -0,0 +1,130 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceUpdateCmd struct { + cmd *cobra.Command + + name string + description string + sourceType string + config string + configFile string + output string + + sourceConfigFlags +} + +func newSourceUpdateCmd() *sourceUpdateCmd { + sc := &sourceUpdateCmd{} + + sc.cmd = &cobra.Command{ + Use: "update ", + Args: validators.ExactArgs(1), + Short: ShortUpdate(ResourceSource), + Long: LongUpdateIntro(ResourceSource) + ` + +Examples: + hookdeck gateway source update src_abc123 --name new-name + hookdeck gateway source update src_abc123 --description "Updated" + hookdeck gateway source update src_abc123 --config '{"webhook_secret":"whsec_new"}'`, + PreRunE: sc.validateFlags, + RunE: sc.runSourceUpdateCmd, + } + + sc.cmd.Flags().StringVar(&sc.name, "name", "", "New source name") + sc.cmd.Flags().StringVar(&sc.description, "description", "", "New source description") + sc.cmd.Flags().StringVar(&sc.sourceType, "type", "", "Source type (e.g. WEBHOOK, STRIPE)") + sc.cmd.Flags().StringVar(&sc.config, "config", "", "JSON object for source config (overrides individual flags if set)") + sc.cmd.Flags().StringVar(&sc.configFile, "config-file", "", "Path to JSON file for source config (overrides individual flags if set)") + sc.cmd.Flags().StringVar(&sc.WebhookSecret, "webhook-secret", "", "Webhook secret for source verification (e.g., Stripe)") + sc.cmd.Flags().StringVar(&sc.APIKey, "api-key", "", "API key for source authentication") + sc.cmd.Flags().StringVar(&sc.BasicAuthUser, "basic-auth-user", "", "Username for Basic authentication") + sc.cmd.Flags().StringVar(&sc.BasicAuthPass, "basic-auth-pass", "", "Password for Basic authentication") + sc.cmd.Flags().StringVar(&sc.HMACSecret, "hmac-secret", "", "HMAC secret for signature verification") + sc.cmd.Flags().StringVar(&sc.HMACAlgo, "hmac-algo", "", "HMAC algorithm (SHA256, etc.)") + sc.cmd.Flags().StringVar(&sc.AllowedHTTPMethods, "allowed-http-methods", "", "Comma-separated allowed HTTP methods (GET, POST, PUT, PATCH, DELETE)") + sc.cmd.Flags().StringVar(&sc.CustomResponseBody, "custom-response-body", "", "Custom response body (max 1000 chars)") + sc.cmd.Flags().StringVar(&sc.CustomResponseType, "custom-response-content-type", "", "Custom response content type (json, text, xml)") + sc.cmd.Flags().StringVar(&sc.output, "output", "", "Output format (json)") + + return sc +} + +// sourceUpdateRequestEmpty reports whether the update request has no fields set (all omitted). +// OpenAPI .plans/openapi-2025-07-01.json PUT /sources/{id} allows name, type, description, config. +func sourceUpdateRequestEmpty(req *hookdeck.SourceUpdateRequest) bool { + return req.Name == "" && req.Description == nil && req.Type == "" && len(req.Config) == 0 +} + +func (sc *sourceUpdateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if sc.config != "" && sc.configFile != "" { + return fmt.Errorf("cannot use both --config and --config-file") + } + return nil +} + +func (sc *sourceUpdateCmd) runSourceUpdateCmd(cmd *cobra.Command, args []string) error { + sourceID := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + // Build update request from flags (only set non-zero values). Use SourceUpdateRequest so + // omitted fields are not sent (PUT /sources/{id} has no required fields). + req := &hookdeck.SourceUpdateRequest{} + req.Name = sc.name + if sc.description != "" { + req.Description = &sc.description + } + if sc.sourceType != "" { + req.Type = strings.ToUpper(sc.sourceType) + } + config, err := buildSourceConfigFromFlags(sc.config, sc.configFile, &sc.sourceConfigFlags, sc.sourceType) + if err != nil { + return err + } + if config != nil { + ensureSourceConfigAuthTypeForHTTP(config, sc.sourceType) + } + if len(config) > 0 { + req.Config = config + } + + // Only send fields that were explicitly set. Spec: PUT /sources/{id} allows name, type, description, config. + if sourceUpdateRequestEmpty(req) { + return fmt.Errorf("no updates specified (set at least one of --name, --description, --type, or config flags)") + } + + src, err := client.UpdateSource(ctx, sourceID, req) + if err != nil { + return fmt.Errorf("failed to update source: %w", err) + } + + if sc.output == "json" { + jsonBytes, err := json.MarshalIndent(src, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal source to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Source updated successfully\n\n") + fmt.Printf("Source: %s (%s)\n", src.Name, src.ID) + fmt.Printf("Type: %s\n", src.Type) + fmt.Printf("URL: %s\n", src.URL) + return nil +} diff --git a/pkg/cmd/source_upsert.go b/pkg/cmd/source_upsert.go new file mode 100644 index 0000000..6bd3b62 --- /dev/null +++ b/pkg/cmd/source_upsert.go @@ -0,0 +1,141 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type sourceUpsertCmd struct { + cmd *cobra.Command + + name string + description string + sourceType string + config string + configFile string + dryRun bool + output string + + sourceConfigFlags +} + +func newSourceUpsertCmd() *sourceUpsertCmd { + sc := &sourceUpsertCmd{} + + sc.cmd = &cobra.Command{ + Use: "upsert ", + Args: validators.ExactArgs(1), + Short: ShortUpsert(ResourceSource), + Long: LongUpsertIntro(ResourceSource) + ` + +Examples: + hookdeck gateway source upsert my-webhook --type WEBHOOK + hookdeck gateway source upsert stripe-prod --type STRIPE --config '{"webhook_secret":"whsec_xxx"}' + hookdeck gateway source upsert my-webhook --description "Updated" --dry-run`, + PreRunE: sc.validateFlags, + RunE: sc.runSourceUpsertCmd, + } + + sc.cmd.Flags().StringVar(&sc.description, "description", "", "Source description") + sc.cmd.Flags().StringVar(&sc.sourceType, "type", "", "Source type (e.g. WEBHOOK, STRIPE)") + sc.cmd.Flags().StringVar(&sc.config, "config", "", "JSON object for source config (overrides individual flags if set)") + sc.cmd.Flags().StringVar(&sc.configFile, "config-file", "", "Path to JSON file for source config (overrides individual flags if set)") + sc.cmd.Flags().StringVar(&sc.WebhookSecret, "webhook-secret", "", "Webhook secret for source verification (e.g., Stripe)") + sc.cmd.Flags().StringVar(&sc.APIKey, "api-key", "", "API key for source authentication") + sc.cmd.Flags().StringVar(&sc.BasicAuthUser, "basic-auth-user", "", "Username for Basic authentication") + sc.cmd.Flags().StringVar(&sc.BasicAuthPass, "basic-auth-pass", "", "Password for Basic authentication") + sc.cmd.Flags().StringVar(&sc.HMACSecret, "hmac-secret", "", "HMAC secret for signature verification") + sc.cmd.Flags().StringVar(&sc.HMACAlgo, "hmac-algo", "", "HMAC algorithm (SHA256, etc.)") + sc.cmd.Flags().StringVar(&sc.AllowedHTTPMethods, "allowed-http-methods", "", "Comma-separated allowed HTTP methods (GET, POST, PUT, PATCH, DELETE)") + sc.cmd.Flags().StringVar(&sc.CustomResponseBody, "custom-response-body", "", "Custom response body (max 1000 chars)") + sc.cmd.Flags().StringVar(&sc.CustomResponseType, "custom-response-content-type", "", "Custom response content type (json, text, xml)") + sc.cmd.Flags().BoolVar(&sc.dryRun, "dry-run", false, "Preview changes without applying") + sc.cmd.Flags().StringVar(&sc.output, "output", "", "Output format (json)") + + return sc +} + +func (sc *sourceUpsertCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + sc.name = args[0] + if sc.config != "" && sc.configFile != "" { + return fmt.Errorf("cannot use both --config and --config-file") + } + auth := sourceAuthFlags{ + WebhookSecret: sc.WebhookSecret, + APIKey: sc.APIKey, + BasicAuthUser: sc.BasicAuthUser, + BasicAuthPass: sc.BasicAuthPass, + HMACSecret: sc.HMACSecret, + } + return validateSourceAuthFromSpec(sc.sourceType, sc.config != "" || sc.configFile != "", auth, "") +} + +func (sc *sourceUpsertCmd) runSourceUpsertCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + config, err := buildSourceConfigFromFlags(sc.config, sc.configFile, &sc.sourceConfigFlags, sc.sourceType) + if err != nil { + return err + } + if config != nil { + ensureSourceConfigAuthTypeForHTTP(config, sc.sourceType) + } + + req := &hookdeck.SourceCreateRequest{ + Name: sc.name, + } + if sc.description != "" { + req.Description = &sc.description + } + if sc.sourceType != "" { + req.Type = strings.ToUpper(sc.sourceType) + } + if len(config) > 0 { + req.Config = config + } + + if sc.dryRun { + params := map[string]string{"name": sc.name} + existing, err := client.ListSources(ctx, params) + if err != nil { + return fmt.Errorf("dry-run: failed to check existing source: %w", err) + } + if existing.Models != nil && len(existing.Models) > 0 { + fmt.Printf("-- Dry Run: UPDATE --\nSource '%s' (%s) would be updated.\n", sc.name, existing.Models[0].ID) + } else { + fmt.Printf("-- Dry Run: CREATE --\nSource '%s' would be created.\n", sc.name) + } + return nil + } + + src, err := client.UpsertSource(ctx, req) + if err != nil { + return fmt.Errorf("failed to upsert source: %w", err) + } + + if sc.output == "json" { + jsonBytes, err := json.MarshalIndent(src, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal source to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Source upserted successfully\n\n") + fmt.Printf("Source: %s (%s)\n", src.Name, src.ID) + fmt.Printf("Type: %s\n", src.Type) + fmt.Printf("URL: %s\n", src.URL) + return nil +} diff --git a/pkg/cmd/transformation.go b/pkg/cmd/transformation.go new file mode 100644 index 0000000..157afc4 --- /dev/null +++ b/pkg/cmd/transformation.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationCmd struct { + cmd *cobra.Command +} + +func newTransformationCmd() *transformationCmd { + tc := &transformationCmd{} + + tc.cmd = &cobra.Command{ + Use: "transformation", + Aliases: []string{"transformations"}, + Args: validators.NoArgs, + Short: "Manage your transformations", + Long: `Manage JavaScript transformations for request/response processing. + +Transformations run custom code to modify event payloads. Create with --name and --code (or --code-file), +then attach to connections via rules. Use 'transformation run' to test code locally.`, + } + + tc.cmd.AddCommand(newTransformationListCmd().cmd) + tc.cmd.AddCommand(newTransformationGetCmd().cmd) + tc.cmd.AddCommand(newTransformationCreateCmd().cmd) + tc.cmd.AddCommand(newTransformationUpsertCmd().cmd) + tc.cmd.AddCommand(newTransformationUpdateCmd().cmd) + tc.cmd.AddCommand(newTransformationDeleteCmd().cmd) + tc.cmd.AddCommand(newTransformationCountCmd().cmd) + tc.cmd.AddCommand(newTransformationRunCmd().cmd) + tc.cmd.AddCommand(newTransformationExecutionsCmd()) + + return tc +} + +// addTransformationCmdTo registers the transformation command tree on the given parent (e.g. gateway). +func addTransformationCmdTo(parent *cobra.Command) { + parent.AddCommand(newTransformationCmd().cmd) +} diff --git a/pkg/cmd/transformation_count.go b/pkg/cmd/transformation_count.go new file mode 100644 index 0000000..f963108 --- /dev/null +++ b/pkg/cmd/transformation_count.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "fmt" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationCountCmd struct { + cmd *cobra.Command + name string + output string +} + +func newTransformationCountCmd() *transformationCountCmd { + tc := &transformationCountCmd{} + + tc.cmd = &cobra.Command{ + Use: "count", + Args: validators.NoArgs, + Short: "Count transformations", + Long: `Count transformations matching optional filters. + +Examples: + hookdeck gateway transformation count + hookdeck gateway transformation count --name my-transform`, + RunE: tc.runTransformationCountCmd, + } + + tc.cmd.Flags().StringVar(&tc.name, "name", "", "Filter by transformation name") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationCountCmd) runTransformationCountCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + if tc.name != "" { + params["name"] = tc.name + } + + resp, err := client.CountTransformations(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to count transformations: %w", err) + } + + if tc.output == "json" { + fmt.Printf(`{"count":%d}`+"\n", resp.Count) + return nil + } + + fmt.Println(strconv.Itoa(resp.Count)) + return nil +} diff --git a/pkg/cmd/transformation_create.go b/pkg/cmd/transformation_create.go new file mode 100644 index 0000000..e478e49 --- /dev/null +++ b/pkg/cmd/transformation_create.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationCreateCmd struct { + cmd *cobra.Command + name string + code string + codeFile string + env string + output string +} + +func newTransformationCreateCmd() *transformationCreateCmd { + tc := &transformationCreateCmd{} + + tc.cmd = &cobra.Command{ + Use: "create", + Args: validators.NoArgs, + Short: ShortCreate(ResourceTransformation), + Long: `Create a new transformation. + +Requires --name and --code (or --code-file). Use --env for key-value environment variables. + +Examples: + hookdeck gateway transformation create --name my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" + hookdeck gateway transformation create --name my-transform --code-file ./transform.js --env FOO=bar,BAZ=qux`, + PreRunE: tc.validateFlags, + RunE: tc.runTransformationCreateCmd, + } + + tc.cmd.Flags().StringVar(&tc.name, "name", "", "Transformation name (required)") + tc.cmd.Flags().StringVar(&tc.code, "code", "", "JavaScript code string (required if --code-file not set)") + tc.cmd.Flags().StringVar(&tc.codeFile, "code-file", "", "Path to JavaScript file (required if --code not set)") + tc.cmd.Flags().StringVar(&tc.env, "env", "", "Environment variables as KEY=value,KEY2=value2") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationCreateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if tc.code == "" && tc.codeFile == "" { + return fmt.Errorf("either --code or --code-file is required") + } + if tc.code != "" && tc.codeFile != "" { + return fmt.Errorf("cannot use both --code and --code-file") + } + if tc.name == "" { + return fmt.Errorf("--name is required") + } + return nil +} + +func (tc *transformationCreateCmd) runTransformationCreateCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + code := tc.code + if tc.codeFile != "" { + b, err := os.ReadFile(tc.codeFile) + if err != nil { + return fmt.Errorf("failed to read --code-file: %w", err) + } + code = string(b) + } + + envMap, err := parseEnvFlag(tc.env) + if err != nil { + return err + } + + req := &hookdeck.TransformationCreateRequest{ + Name: tc.name, + Code: code, + Env: envMap, + } + + t, err := client.CreateTransformation(ctx, req) + if err != nil { + return fmt.Errorf("failed to create transformation: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal transformation to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Transformation created successfully\n\n") + fmt.Printf("Transformation: %s (%s)\n", t.Name, t.ID) + return nil +} + +// parseEnvFlag parses KEY=value,KEY2=value2 into map[string]string. +func parseEnvFlag(s string) (map[string]string, error) { + if s == "" { + return nil, nil + } + out := make(map[string]string) + for _, pair := range strings.Split(s, ",") { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) != 2 { + return nil, fmt.Errorf("invalid env pair %q (expected KEY=value)", pair) + } + out[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + return out, nil +} diff --git a/pkg/cmd/transformation_delete.go b/pkg/cmd/transformation_delete.go new file mode 100644 index 0000000..778cfee --- /dev/null +++ b/pkg/cmd/transformation_delete.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationDeleteCmd struct { + cmd *cobra.Command + force bool +} + +func newTransformationDeleteCmd() *transformationDeleteCmd { + tc := &transformationDeleteCmd{} + + tc.cmd = &cobra.Command{ + Use: "delete ", + Args: validators.ExactArgs(1), + Short: ShortDelete(ResourceTransformation), + Long: LongDeleteIntro(ResourceTransformation) + ` + +Examples: + hookdeck gateway transformation delete trn_abc123 + hookdeck gateway transformation delete trn_abc123 --force`, + PreRunE: tc.validateFlags, + RunE: tc.runTransformationDeleteCmd, + } + + tc.cmd.Flags().BoolVar(&tc.force, "force", false, "Force delete without confirmation") + + return tc +} + +func (tc *transformationDeleteCmd) validateFlags(cmd *cobra.Command, args []string) error { + return Config.Profile.ValidateAPIKey() +} + +func (tc *transformationDeleteCmd) runTransformationDeleteCmd(cmd *cobra.Command, args []string) error { + idOrName := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + trnID, err := resolveTransformationID(ctx, client, idOrName) + if err != nil { + return err + } + + t, err := client.GetTransformation(ctx, trnID) + if err != nil { + return fmt.Errorf("failed to get transformation: %w", err) + } + + if !tc.force { + fmt.Printf("\nAre you sure you want to delete transformation '%s' (%s)? [y/N]: ", t.Name, trnID) + var response string + fmt.Scanln(&response) + if response != "y" && response != "Y" { + fmt.Println("Deletion cancelled.") + return nil + } + } + + if err := client.DeleteTransformation(ctx, trnID); err != nil { + return fmt.Errorf("failed to delete transformation: %w", err) + } + + fmt.Printf("βœ” Transformation deleted: %s (%s)\n", t.Name, trnID) + return nil +} diff --git a/pkg/cmd/transformation_executions.go b/pkg/cmd/transformation_executions.go new file mode 100644 index 0000000..ff4424c --- /dev/null +++ b/pkg/cmd/transformation_executions.go @@ -0,0 +1,194 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +// newTransformationExecutionsCmd returns the "executions" parent command with list and get subcommands. +func newTransformationExecutionsCmd() *cobra.Command { + exec := &cobra.Command{ + Use: "executions", + Short: "List or get transformation executions", + Long: `List executions for a transformation, or get a single execution by ID.`, + } + exec.AddCommand(newTransformationExecutionsListCmd().cmd) + exec.AddCommand(newTransformationExecutionsGetCmd().cmd) + return exec +} + +type transformationExecutionsListCmd struct { + cmd *cobra.Command + trnID string + logLevel string + connectionID string + issueID string + createdAt string + orderBy string + dir string + limit int + next string + prev string + output string +} + +func newTransformationExecutionsListCmd() *transformationExecutionsListCmd { + tc := &transformationExecutionsListCmd{} + + tc.cmd = &cobra.Command{ + Use: "list ", + Args: validators.ExactArgs(1), + Short: "List transformation executions", + Long: `List executions for a transformation.`, + RunE: tc.run, + } + + tc.cmd.Flags().StringVar(&tc.logLevel, "log-level", "", "Filter by log level (debug, info, warn, error, fatal)") + tc.cmd.Flags().StringVar(&tc.connectionID, "connection-id", "", "Filter by connection ID") + tc.cmd.Flags().StringVar(&tc.issueID, "issue-id", "", "Filter by issue ID") + tc.cmd.Flags().StringVar(&tc.createdAt, "created-at", "", "Filter by created_at (ISO date or operator)") + tc.cmd.Flags().StringVar(&tc.orderBy, "order-by", "", "Sort key (created_at)") + tc.cmd.Flags().StringVar(&tc.dir, "dir", "", "Sort direction (asc, desc)") + tc.cmd.Flags().IntVar(&tc.limit, "limit", 100, "Limit number of results") + tc.cmd.Flags().StringVar(&tc.next, "next", "", "Pagination cursor for next page") + tc.cmd.Flags().StringVar(&tc.prev, "prev", "", "Pagination cursor for previous page") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationExecutionsListCmd) run(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + tc.trnID = args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + trnID, err := resolveTransformationID(ctx, client, tc.trnID) + if err != nil { + return err + } + + params := make(map[string]string) + if tc.logLevel != "" { + params["log_level"] = tc.logLevel + } + if tc.connectionID != "" { + params["webhook_id"] = tc.connectionID + } + if tc.issueID != "" { + params["issue_id"] = tc.issueID + } + if tc.createdAt != "" { + params["created_at"] = tc.createdAt + } + if tc.orderBy != "" { + params["order_by"] = tc.orderBy + } + if tc.dir != "" { + params["dir"] = tc.dir + } + params["limit"] = strconv.Itoa(tc.limit) + if tc.next != "" { + params["next"] = tc.next + } + if tc.prev != "" { + params["prev"] = tc.prev + } + + resp, err := client.ListTransformationExecutions(ctx, trnID, params) + if err != nil { + return fmt.Errorf("failed to list executions: %w", err) + } + + if tc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal executions to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No executions found.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, e := range resp.Models { + fmt.Printf("%s %s\n", color.Green(e.ID), e.CreatedAt.Format("2006-01-02 15:04:05")) + } + return nil +} + +type transformationExecutionsGetCmd struct { + cmd *cobra.Command + output string +} + +func newTransformationExecutionsGetCmd() *transformationExecutionsGetCmd { + tc := &transformationExecutionsGetCmd{} + + tc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(2), + Short: "Get a transformation execution", + Long: `Get a single execution by transformation ID and execution ID.`, + RunE: tc.run, + } + + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationExecutionsGetCmd) run(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + trnIDOrName := args[0] + executionID := args[1] + client := Config.GetAPIClient() + ctx := context.Background() + + trnID, err := resolveTransformationID(ctx, client, trnIDOrName) + if err != nil { + return err + } + + exec, err := client.GetTransformationExecution(ctx, trnID, executionID) + if err != nil { + return fmt.Errorf("failed to get execution: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := json.MarshalIndent(exec, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal execution to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(exec.ID)) + fmt.Printf(" Created: %s\n", exec.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + return nil +} diff --git a/pkg/cmd/transformation_get.go b/pkg/cmd/transformation_get.go new file mode 100644 index 0000000..0fc51a2 --- /dev/null +++ b/pkg/cmd/transformation_get.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationGetCmd struct { + cmd *cobra.Command + output string +} + +func newTransformationGetCmd() *transformationGetCmd { + tc := &transformationGetCmd{} + + tc.cmd = &cobra.Command{ + Use: "get ", + Args: validators.ExactArgs(1), + Short: ShortGet(ResourceTransformation), + Long: LongGetIntro(ResourceTransformation) + ` + +Examples: + hookdeck gateway transformation get trn_abc123 + hookdeck gateway transformation get my-transform`, + RunE: tc.runTransformationGetCmd, + } + + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationGetCmd) runTransformationGetCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + idOrName := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + trnID, err := resolveTransformationID(ctx, client, idOrName) + if err != nil { + return err + } + + t, err := client.GetTransformation(ctx, trnID) + if err != nil { + return fmt.Errorf("failed to get transformation: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal transformation to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + color := ansi.Color(os.Stdout) + fmt.Printf("\n%s\n", color.Green(t.Name)) + fmt.Printf(" ID: %s\n", t.ID) + fmt.Printf(" Code: %s\n", truncate(t.Code, 80)) + if len(t.Env) > 0 { + fmt.Printf(" Env: %v\n", t.Env) + } + fmt.Printf(" Created: %s\n", t.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Updated: %s\n", t.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Println() + + return nil +} + +func truncate(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} + +// resolveTransformationID returns the transformation ID for the given name or ID. +func resolveTransformationID(ctx context.Context, client *hookdeck.Client, nameOrID string) (string, error) { + // If it looks like an ID (e.g. trs_xxx), try Get first + if strings.HasPrefix(nameOrID, "trs_") { + _, err := client.GetTransformation(ctx, nameOrID) + if err == nil { + return nameOrID, nil + } + errMsg := strings.ToLower(err.Error()) + if !strings.Contains(errMsg, "404") && !strings.Contains(errMsg, "not found") { + return "", err + } + } + + params := map[string]string{"name": nameOrID} + result, err := client.ListTransformations(ctx, params) + if err != nil { + return "", fmt.Errorf("failed to lookup transformation by name '%s': %w", nameOrID, err) + } + if len(result.Models) == 0 { + return "", fmt.Errorf("no transformation found with name or ID '%s'", nameOrID) + } + return result.Models[0].ID, nil +} diff --git a/pkg/cmd/transformation_list.go b/pkg/cmd/transformation_list.go new file mode 100644 index 0000000..ae72821 --- /dev/null +++ b/pkg/cmd/transformation_list.go @@ -0,0 +1,113 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/ansi" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationListCmd struct { + cmd *cobra.Command + + id string + name string + orderBy string + dir string + limit int + next string + prev string + output string +} + +func newTransformationListCmd() *transformationListCmd { + tc := &transformationListCmd{} + + tc.cmd = &cobra.Command{ + Use: "list", + Args: validators.NoArgs, + Short: ShortList(ResourceTransformation), + Long: `List all transformations or filter by name or id. + +Examples: + hookdeck gateway transformation list + hookdeck gateway transformation list --name my-transform + hookdeck gateway transformation list --order-by created_at --dir desc + hookdeck gateway transformation list --limit 10`, + RunE: tc.runTransformationListCmd, + } + + tc.cmd.Flags().StringVar(&tc.id, "id", "", "Filter by transformation ID(s)") + tc.cmd.Flags().StringVar(&tc.name, "name", "", "Filter by transformation name") + tc.cmd.Flags().StringVar(&tc.orderBy, "order-by", "", "Sort key (name, created_at, updated_at)") + tc.cmd.Flags().StringVar(&tc.dir, "dir", "", "Sort direction (asc, desc)") + tc.cmd.Flags().IntVar(&tc.limit, "limit", 100, "Limit number of results") + tc.cmd.Flags().StringVar(&tc.next, "next", "", "Pagination cursor for next page") + tc.cmd.Flags().StringVar(&tc.prev, "prev", "", "Pagination cursor for previous page") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationListCmd) runTransformationListCmd(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + + client := Config.GetAPIClient() + params := make(map[string]string) + if tc.id != "" { + params["id"] = tc.id + } + if tc.name != "" { + params["name"] = tc.name + } + if tc.orderBy != "" { + params["order_by"] = tc.orderBy + } + if tc.dir != "" { + params["dir"] = tc.dir + } + params["limit"] = strconv.Itoa(tc.limit) + if tc.next != "" { + params["next"] = tc.next + } + if tc.prev != "" { + params["prev"] = tc.prev + } + + resp, err := client.ListTransformations(context.Background(), params) + if err != nil { + return fmt.Errorf("failed to list transformations: %w", err) + } + + if tc.output == "json" { + if len(resp.Models) == 0 { + fmt.Println("[]") + return nil + } + jsonBytes, err := json.MarshalIndent(resp.Models, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal transformations to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + if len(resp.Models) == 0 { + fmt.Println("No transformations found.") + return nil + } + + color := ansi.Color(os.Stdout) + for _, t := range resp.Models { + fmt.Printf("%s %s\n", color.Green(t.Name), t.ID) + } + return nil +} diff --git a/pkg/cmd/transformation_run.go b/pkg/cmd/transformation_run.go new file mode 100644 index 0000000..bb16a29 --- /dev/null +++ b/pkg/cmd/transformation_run.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationRunCmd struct { + cmd *cobra.Command + code string + codeFile string + transformationID string + request string + requestFile string + connectionID string + env string + output string +} + +func newTransformationRunCmd() *transformationRunCmd { + tc := &transformationRunCmd{} + + tc.cmd = &cobra.Command{ + Use: "run", + Args: validators.NoArgs, + Short: "Run transformation code (test)", + Long: `Test run transformation code against a sample request. + +Provide either inline --code/--code-file or --id to use an existing transformation. +The --request or --request-file must be JSON with at least "headers" (can be {}). Optional: body, path, query. + +Examples: + hookdeck gateway transformation run --id trs_abc123 --request '{"headers":{}}' + hookdeck gateway transformation run --code "addHandler(\"transform\", (request, context) => { return request; });" --request-file ./sample.json + hookdeck gateway transformation run --id trs_abc123 --request '{"headers":{},"body":{"foo":"bar"}}' --connection-id web_xxx`, + PreRunE: tc.validateFlags, + RunE: tc.runTransformationRunCmd, + } + + tc.cmd.Flags().StringVar(&tc.code, "code", "", "JavaScript code string to run") + tc.cmd.Flags().StringVar(&tc.codeFile, "code-file", "", "Path to JavaScript file") + tc.cmd.Flags().StringVar(&tc.transformationID, "id", "", "Use existing transformation by ID") + tc.cmd.Flags().StringVar(&tc.request, "request", "", "Request JSON (must include headers, e.g. {\"headers\":{}})") + tc.cmd.Flags().StringVar(&tc.requestFile, "request-file", "", "Path to request JSON file") + tc.cmd.Flags().StringVar(&tc.connectionID, "connection-id", "", "Connection ID for execution context") + tc.cmd.Flags().StringVar(&tc.env, "env", "", "Environment variables as KEY=value,KEY2=value2") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationRunCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if tc.code == "" && tc.codeFile == "" && tc.transformationID == "" { + return fmt.Errorf("either --code, --code-file, or --id is required") + } + if tc.code != "" && tc.codeFile != "" { + return fmt.Errorf("cannot use both --code and --code-file") + } + if tc.request != "" && tc.requestFile != "" { + return fmt.Errorf("cannot use both --request and --request-file") + } + if tc.request == "" && tc.requestFile == "" { + return fmt.Errorf("--request or --request-file is required (use {\"headers\":{}} for minimal request)") + } + return nil +} + +func (tc *transformationRunCmd) runTransformationRunCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + code := tc.code + if tc.codeFile != "" { + b, err := os.ReadFile(tc.codeFile) + if err != nil { + return fmt.Errorf("failed to read --code-file: %w", err) + } + code = string(b) + } + + requestJSON := tc.request + if tc.requestFile != "" { + b, err := os.ReadFile(tc.requestFile) + if err != nil { + return fmt.Errorf("failed to read --request-file: %w", err) + } + requestJSON = string(b) + } + + var requestInput hookdeck.TransformationRunRequestInput + if err := json.Unmarshal([]byte(requestJSON), &requestInput); err != nil { + return fmt.Errorf("invalid --request JSON: %w", err) + } + if requestInput.Headers == nil { + requestInput.Headers = make(map[string]string) + } + // Ensure content-type when empty so transformation engine does not error + if requestInput.Headers["content-type"] == "" && requestInput.Headers["Content-Type"] == "" { + requestInput.Headers["content-type"] = "application/json" + } + + envMap, err := parseEnvFlag(tc.env) + if err != nil { + return err + } + + req := &hookdeck.TransformationRunRequest{ + Request: &requestInput, + Env: envMap, + WebhookID: tc.connectionID, + } + if code != "" { + req.Code = code + } + if tc.transformationID != "" { + req.TransformationID = tc.transformationID + } + + result, err := client.RunTransformation(ctx, req) + if err != nil { + return fmt.Errorf("failed to run transformation: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := json.MarshalIndent(result, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal result to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Transformation run completed\n\n") + if result.Request != nil { + // Pretty-print the transformed request as JSON + jsonBytes, err := json.MarshalIndent(result.Request, "", " ") + if err != nil { + fmt.Printf("Result: %v\n", result.Request) + } else { + fmt.Printf("Result:\n%s\n", string(jsonBytes)) + } + } + return nil +} diff --git a/pkg/cmd/transformation_update.go b/pkg/cmd/transformation_update.go new file mode 100644 index 0000000..615290a --- /dev/null +++ b/pkg/cmd/transformation_update.go @@ -0,0 +1,116 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationUpdateCmd struct { + cmd *cobra.Command + name string + code string + codeFile string + env string + output string +} + +func newTransformationUpdateCmd() *transformationUpdateCmd { + tc := &transformationUpdateCmd{} + + tc.cmd = &cobra.Command{ + Use: "update ", + Args: validators.ExactArgs(1), + Short: ShortUpdate(ResourceTransformation), + Long: LongUpdateIntro(ResourceTransformation) + ` + +Examples: + hookdeck gateway transformation update trn_abc123 --name new-name + hookdeck gateway transformation update my-transform --code-file ./transform.js + hookdeck gateway transformation update trn_abc123 --env FOO=bar`, + PreRunE: tc.validateFlags, + RunE: tc.runTransformationUpdateCmd, + } + + tc.cmd.Flags().StringVar(&tc.name, "name", "", "New transformation name") + tc.cmd.Flags().StringVar(&tc.code, "code", "", "New JavaScript code string") + tc.cmd.Flags().StringVar(&tc.codeFile, "code-file", "", "Path to JavaScript file") + tc.cmd.Flags().StringVar(&tc.env, "env", "", "Environment variables as KEY=value,KEY2=value2") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationUpdateCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + if tc.code != "" && tc.codeFile != "" { + return fmt.Errorf("cannot use both --code and --code-file") + } + return nil +} + +func (tc *transformationUpdateCmd) runTransformationUpdateCmd(cmd *cobra.Command, args []string) error { + idOrName := args[0] + client := Config.GetAPIClient() + ctx := context.Background() + + trnID, err := resolveTransformationID(ctx, client, idOrName) + if err != nil { + return err + } + + // Partial update: only set fields that were provided + req := &hookdeck.TransformationUpdateRequest{} + if tc.name != "" { + req.Name = tc.name + } + if tc.code != "" { + req.Code = tc.code + } + if tc.codeFile != "" { + b, err := os.ReadFile(tc.codeFile) + if err != nil { + return fmt.Errorf("failed to read --code-file: %w", err) + } + req.Code = string(b) + } + if tc.env != "" { + envMap, err := parseEnvFlag(tc.env) + if err != nil { + return err + } + req.Env = envMap + } + + // At least one field must change + hasUpdate := req.Name != "" || req.Code != "" || len(req.Env) > 0 + if !hasUpdate { + return fmt.Errorf("no updates specified (set at least one of --name, --code, --code-file, or --env)") + } + + t, err := client.UpdateTransformation(ctx, trnID, req) + if err != nil { + return fmt.Errorf("failed to update transformation: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal transformation to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Transformation updated successfully\n\n") + fmt.Printf("Transformation: %s (%s)\n", t.Name, t.ID) + return nil +} diff --git a/pkg/cmd/transformation_upsert.go b/pkg/cmd/transformation_upsert.go new file mode 100644 index 0000000..fe52bbb --- /dev/null +++ b/pkg/cmd/transformation_upsert.go @@ -0,0 +1,135 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/hookdeck/hookdeck-cli/pkg/hookdeck" + "github.com/hookdeck/hookdeck-cli/pkg/validators" +) + +type transformationUpsertCmd struct { + cmd *cobra.Command + name string + code string + codeFile string + env string + dryRun bool + output string +} + +func newTransformationUpsertCmd() *transformationUpsertCmd { + tc := &transformationUpsertCmd{} + + tc.cmd = &cobra.Command{ + Use: "upsert ", + Args: validators.ExactArgs(1), + Short: ShortUpsert(ResourceTransformation), + Long: LongUpsertIntro(ResourceTransformation) + ` + +Examples: + hookdeck gateway transformation upsert my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" + hookdeck gateway transformation upsert my-transform --code-file ./transform.js --env FOO=bar + hookdeck gateway transformation upsert my-transform --code "addHandler(\"transform\", (request, context) => { return request; });" --dry-run`, + PreRunE: tc.validateFlags, + RunE: tc.runTransformationUpsertCmd, + } + + tc.cmd.Flags().StringVar(&tc.code, "code", "", "JavaScript code string") + tc.cmd.Flags().StringVar(&tc.codeFile, "code-file", "", "Path to JavaScript file") + tc.cmd.Flags().StringVar(&tc.env, "env", "", "Environment variables as KEY=value,KEY2=value2") + tc.cmd.Flags().BoolVar(&tc.dryRun, "dry-run", false, "Preview changes without applying") + tc.cmd.Flags().StringVar(&tc.output, "output", "", "Output format (json)") + + return tc +} + +func (tc *transformationUpsertCmd) validateFlags(cmd *cobra.Command, args []string) error { + if err := Config.Profile.ValidateAPIKey(); err != nil { + return err + } + tc.name = args[0] + if tc.code != "" && tc.codeFile != "" { + return fmt.Errorf("cannot use both --code and --code-file") + } + return nil +} + +func (tc *transformationUpsertCmd) runTransformationUpsertCmd(cmd *cobra.Command, args []string) error { + client := Config.GetAPIClient() + ctx := context.Background() + + code := tc.code + if tc.codeFile != "" { + b, err := os.ReadFile(tc.codeFile) + if err != nil { + return fmt.Errorf("failed to read --code-file: %w", err) + } + code = string(b) + } + + envMap, err := parseEnvFlag(tc.env) + if err != nil { + return err + } + + req := &hookdeck.TransformationCreateRequest{ + Name: tc.name, + Code: code, + Env: envMap, + } + + // API requires name + code on PUT. When user didn't provide code (partial update), fetch existing and merge. + if req.Code == "" { + params := map[string]string{"name": tc.name} + listResp, err := client.ListTransformations(ctx, params) + if err != nil || listResp.Models == nil || len(listResp.Models) == 0 { + return fmt.Errorf("upsert requires --code or --code-file when creating a new transformation; no existing transformation named %q", tc.name) + } + existing := listResp.Models[0] + existingFull, err := client.GetTransformation(ctx, existing.ID) + if err != nil { + return fmt.Errorf("failed to load existing transformation for merge: %w", err) + } + req.Code = existingFull.Code + if len(req.Env) == 0 && len(existingFull.Env) > 0 { + req.Env = existingFull.Env + } + } + + if tc.dryRun { + params := map[string]string{"name": tc.name} + existing, err := client.ListTransformations(ctx, params) + if err != nil { + return fmt.Errorf("dry-run: failed to check existing transformation: %w", err) + } + if existing.Models != nil && len(existing.Models) > 0 { + fmt.Printf("-- Dry Run: UPDATE --\nTransformation '%s' (%s) would be updated.\n", tc.name, existing.Models[0].ID) + } else { + fmt.Printf("-- Dry Run: CREATE --\nTransformation '%s' would be created.\n", tc.name) + } + return nil + } + + t, err := client.UpsertTransformation(ctx, req) + if err != nil { + return fmt.Errorf("failed to upsert transformation: %w", err) + } + + if tc.output == "json" { + jsonBytes, err := json.MarshalIndent(t, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal transformation to json: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil + } + + fmt.Printf("βœ” Transformation upserted successfully\n\n") + fmt.Printf("Transformation: %s (%s)\n", t.Name, t.ID) + return nil +} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go index 1238ce6..e489aec 100644 --- a/pkg/cmd/version.go +++ b/pkg/cmd/version.go @@ -10,9 +10,11 @@ import ( ) var versionCmd = &cobra.Command{ - Use: "version", - Args: validators.NoArgs, - Short: "Get the version of the Hookdeck CLI", + Use: "version", + Args: validators.NoArgs, + Short: "Get the version of the Hookdeck CLI", + Long: "Print the CLI version and check whether a new version is available.", + Example: " $ hookdeck version", Run: func(cmd *cobra.Command, args []string) { fmt.Print(version.Template) diff --git a/pkg/cmd/whoami.go b/pkg/cmd/whoami.go index d563f9f..7e64991 100644 --- a/pkg/cmd/whoami.go +++ b/pkg/cmd/whoami.go @@ -18,10 +18,11 @@ func newWhoamiCmd() *whoamiCmd { lc := &whoamiCmd{} lc.cmd = &cobra.Command{ - Use: "whoami", - Args: validators.NoArgs, - Short: "Show the logged-in user", - RunE: lc.runWhoamiCmd, + Use: "whoami", + Args: validators.NoArgs, + Short: "Show the logged-in user", + Example: " $ hookdeck whoami", + RunE: lc.runWhoamiCmd, } return lc diff --git a/pkg/hookdeck/attempts.go b/pkg/hookdeck/attempts.go new file mode 100644 index 0000000..5d50f2c --- /dev/null +++ b/pkg/hookdeck/attempts.go @@ -0,0 +1,66 @@ +package hookdeck + +import ( + "context" + "fmt" + "net/url" + "time" +) + +// EventAttempt represents a single delivery attempt for an event +type EventAttempt struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + EventID string `json:"event_id"` + DestinationID string `json:"destination_id"` + ResponseStatus *int `json:"response_status,omitempty"` + AttemptNumber int `json:"attempt_number"` + Trigger string `json:"trigger"` + ErrorCode *string `json:"error_code,omitempty"` + Body interface{} `json:"body,omitempty"` // API may return string or object + RequestedURL string `json:"requested_url"` + HTTPMethod string `json:"http_method"` + BulkRetryID *string `json:"bulk_retry_id,omitempty"` + Status string `json:"status"` + SuccessfulAt *time.Time `json:"successful_at,omitempty"` + DeliveredAt *time.Time `json:"delivered_at,omitempty"` +} + +// EventAttemptListResponse is the response from listing attempts (EventAttemptPaginatedResult) +type EventAttemptListResponse struct { + Models []EventAttempt `json:"models"` + Pagination PaginationResponse `json:"pagination"` + Count *int `json:"count,omitempty"` +} + +// ListAttempts retrieves attempts for an event (params: event_id required; order_by, dir, limit, next, prev) +func (c *Client) ListAttempts(ctx context.Context, params map[string]string) (*EventAttemptListResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + resp, err := c.Get(ctx, APIPathPrefix+"/attempts", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + var result EventAttemptListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse attempt list response: %w", err) + } + return &result, nil +} + +// GetAttempt retrieves a single attempt by ID +func (c *Client) GetAttempt(ctx context.Context, id string) (*EventAttempt, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/attempts/"+id, "", nil) + if err != nil { + return nil, err + } + var attempt EventAttempt + _, err = postprocessJsonResponse(resp, &attempt) + if err != nil { + return nil, fmt.Errorf("failed to parse attempt response: %w", err) + } + return &attempt, nil +} diff --git a/pkg/hookdeck/auth.go b/pkg/hookdeck/auth.go index 3aac5df..55cba8f 100644 --- a/pkg/hookdeck/auth.go +++ b/pkg/hookdeck/auth.go @@ -75,7 +75,7 @@ func (c *Client) StartLogin(deviceName string) (*LoginSession, error) { return nil, err } - res, err := c.Post(context.Background(), "/2025-07-01/cli-auth", jsonData, nil) + res, err := c.Post(context.Background(), APIPathPrefix+"/cli-auth", jsonData, nil) if err != nil { return nil, err } @@ -129,13 +129,13 @@ func (s *GuestSession) WaitForAPIKey(interval time.Duration, maxAttempts int) (* // PollForAPIKeyWithKey polls for login completion using a CLI API key (for interactive login) func (c *Client) PollForAPIKeyWithKey(apiKey string, interval time.Duration, maxAttempts int) (*PollAPIKeyResponse, error) { - pollURL := c.BaseURL.String() + "/2025-07-01/cli-auth/poll?key=" + apiKey + pollURL := c.BaseURL.String() + APIPathPrefix + "/cli-auth/poll?key=" + apiKey return pollForAPIKey(pollURL, interval, maxAttempts) } // ValidateAPIKey validates an API key and returns user/project information func (c *Client) ValidateAPIKey() (*ValidateAPIKeyResponse, error) { - res, err := c.Get(context.Background(), "/2025-07-01/cli-auth/validate", "", nil) + res, err := c.Get(context.Background(), APIPathPrefix+"/cli-auth/validate", "", nil) if err != nil { return nil, err } @@ -244,7 +244,7 @@ func (c *Client) UpdateClient(clientID string, input UpdateClientInput) error { return err } - _, err = c.Put(context.Background(), "/2025-07-01/cli/"+clientID, jsonData, nil) + _, err = c.Put(context.Background(), APIPathPrefix+"/cli/"+clientID, jsonData, nil) return err } diff --git a/pkg/hookdeck/ci.go b/pkg/hookdeck/ci.go index 13aeeb7..f024c3c 100644 --- a/pkg/hookdeck/ci.go +++ b/pkg/hookdeck/ci.go @@ -29,7 +29,7 @@ func (c *Client) CreateCIClient(input CreateCIClientInput) (CIClient, error) { if err != nil { return CIClient{}, err } - res, err := c.Post(context.Background(), "/2025-07-01/cli-auth/ci", input_bytes, nil) + res, err := c.Post(context.Background(), APIPathPrefix+"/cli-auth/ci", input_bytes, nil) if err != nil { return CIClient{}, err } diff --git a/pkg/hookdeck/client.go b/pkg/hookdeck/client.go index 1299a09..75593ae 100644 --- a/pkg/hookdeck/client.go +++ b/pkg/hookdeck/client.go @@ -31,6 +31,11 @@ const DefaultWebsocektURL = "wss://ws.hookdeck.com" const DefaultProfileName = "default" +// APIPathPrefix is the versioned path prefix for all REST API requests. +// Used by connections, sources, destinations, events, auth, etc. +// Change in one place when the API version is updated. +const APIPathPrefix = "/2025-07-01" + // Client is the API client used to sent requests to Hookdeck. type Client struct { // The base URL (protocol + hostname) used for all requests sent by this diff --git a/pkg/hookdeck/connections.go b/pkg/hookdeck/connections.go index 37ce17f..325cabd 100644 --- a/pkg/hookdeck/connections.go +++ b/pkg/hookdeck/connections.go @@ -68,7 +68,7 @@ func (c *Client) ListConnections(ctx context.Context, params map[string]string) queryParams.Add(k, v) } - resp, err := c.Get(ctx, "/2025-07-01/connections", queryParams.Encode(), nil) + resp, err := c.Get(ctx, APIPathPrefix+"/connections", queryParams.Encode(), nil) if err != nil { return nil, err } @@ -84,7 +84,7 @@ func (c *Client) ListConnections(ctx context.Context, params map[string]string) // GetConnection retrieves a single connection by ID func (c *Client) GetConnection(ctx context.Context, id string) (*Connection, error) { - resp, err := c.Get(ctx, fmt.Sprintf("/2025-07-01/connections/%s", id), "", nil) + resp, err := c.Get(ctx, APIPathPrefix+"/connections/"+id, "", nil) if err != nil { return nil, err } @@ -105,7 +105,7 @@ func (c *Client) CreateConnection(ctx context.Context, req *ConnectionCreateRequ return nil, fmt.Errorf("failed to marshal connection request: %w", err) } - resp, err := c.Post(ctx, "/2025-07-01/connections", data, nil) + resp, err := c.Post(ctx, APIPathPrefix+"/connections", data, nil) if err != nil { return nil, err } @@ -127,7 +127,29 @@ func (c *Client) UpsertConnection(ctx context.Context, req *ConnectionCreateRequ return nil, fmt.Errorf("failed to marshal connection upsert request: %w", err) } - resp, err := c.Put(ctx, "/2025-07-01/connections", data, nil) + resp, err := c.Put(ctx, APIPathPrefix+"/connections", data, nil) + if err != nil { + return nil, err + } + + var connection Connection + _, err = postprocessJsonResponse(resp, &connection) + if err != nil { + return nil, fmt.Errorf("failed to parse connection response: %w", err) + } + + return &connection, nil +} + +// UpdateConnection updates an existing connection by ID +// Uses PUT /connections/{id} endpoint +func (c *Client) UpdateConnection(ctx context.Context, id string, req *ConnectionCreateRequest) (*Connection, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal connection update request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/connections/"+id, data, nil) if err != nil { return nil, err } @@ -143,7 +165,7 @@ func (c *Client) UpsertConnection(ctx context.Context, req *ConnectionCreateRequ // DeleteConnection deletes a connection func (c *Client) DeleteConnection(ctx context.Context, id string) error { - url := fmt.Sprintf("/2025-07-01/connections/%s", id) + url := APIPathPrefix + "/connections/" + id req, err := c.newRequest(ctx, "DELETE", url, nil) if err != nil { return err @@ -160,7 +182,7 @@ func (c *Client) DeleteConnection(ctx context.Context, id string) error { // EnableConnection enables a connection func (c *Client) EnableConnection(ctx context.Context, id string) (*Connection, error) { - resp, err := c.Put(ctx, fmt.Sprintf("/2025-07-01/connections/%s/enable", id), []byte("{}"), nil) + resp, err := c.Put(ctx, APIPathPrefix+"/connections/"+id+"/enable", []byte("{}"), nil) if err != nil { return nil, err } @@ -176,7 +198,7 @@ func (c *Client) EnableConnection(ctx context.Context, id string) (*Connection, // DisableConnection disables a connection func (c *Client) DisableConnection(ctx context.Context, id string) (*Connection, error) { - resp, err := c.Put(ctx, fmt.Sprintf("/2025-07-01/connections/%s/disable", id), []byte("{}"), nil) + resp, err := c.Put(ctx, APIPathPrefix+"/connections/"+id+"/disable", []byte("{}"), nil) if err != nil { return nil, err } @@ -192,7 +214,7 @@ func (c *Client) DisableConnection(ctx context.Context, id string) (*Connection, // PauseConnection pauses a connection func (c *Client) PauseConnection(ctx context.Context, id string) (*Connection, error) { - resp, err := c.Put(ctx, fmt.Sprintf("/2025-07-01/connections/%s/pause", id), []byte("{}"), nil) + resp, err := c.Put(ctx, APIPathPrefix+"/connections/"+id+"/pause", []byte("{}"), nil) if err != nil { return nil, err } @@ -208,7 +230,7 @@ func (c *Client) PauseConnection(ctx context.Context, id string) (*Connection, e // UnpauseConnection unpauses a connection func (c *Client) UnpauseConnection(ctx context.Context, id string) (*Connection, error) { - resp, err := c.Put(ctx, fmt.Sprintf("/2025-07-01/connections/%s/unpause", id), []byte("{}"), nil) + resp, err := c.Put(ctx, APIPathPrefix+"/connections/"+id+"/unpause", []byte("{}"), nil) if err != nil { return nil, err } @@ -229,7 +251,7 @@ func (c *Client) CountConnections(ctx context.Context, params map[string]string) queryParams.Add(k, v) } - resp, err := c.Get(ctx, "/2025-07-01/connections/count", queryParams.Encode(), nil) + resp, err := c.Get(ctx, APIPathPrefix+"/connections/count", queryParams.Encode(), nil) if err != nil { return nil, err } diff --git a/pkg/hookdeck/connections_test.go b/pkg/hookdeck/connections_test.go index 8f01963..7c35e42 100644 --- a/pkg/hookdeck/connections_test.go +++ b/pkg/hookdeck/connections_test.go @@ -120,8 +120,8 @@ func TestListConnections(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET request, got %s", r.Method) } - if r.URL.Path != "/2025-07-01/connections" { - t.Errorf("expected path /2025-07-01/connections, got %s", r.URL.Path) + if r.URL.Path != APIPathPrefix+"/connections" { + t.Errorf("expected path %s/connections, got %s", APIPathPrefix, r.URL.Path) } // Verify query parameters @@ -214,7 +214,7 @@ func TestGetConnection(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET request, got %s", r.Method) } - expectedPath := "/2025-07-01/connections/" + tt.connectionID + expectedPath := APIPathPrefix + "/connections/" + tt.connectionID if r.URL.Path != expectedPath { t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) } @@ -334,8 +334,8 @@ func TestCreateConnection(t *testing.T) { if r.Method != http.MethodPost { t.Errorf("expected POST request, got %s", r.Method) } - if r.URL.Path != "/2025-07-01/connections" { - t.Errorf("expected path /2025-07-01/connections, got %s", r.URL.Path) + if r.URL.Path != APIPathPrefix+"/connections" { + t.Errorf("expected path %s/connections, got %s", APIPathPrefix, r.URL.Path) } // Verify request body @@ -409,7 +409,7 @@ func TestDeleteConnection(t *testing.T) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE request, got %s", r.Method) } - expectedPath := "/2025-07-01/connections/" + tt.connectionID + expectedPath := APIPathPrefix + "/connections/" + tt.connectionID if r.URL.Path != expectedPath { t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) } @@ -482,7 +482,7 @@ func TestEnableConnection(t *testing.T) { if r.Method != http.MethodPut { t.Errorf("expected PUT request, got %s", r.Method) } - expectedPath := "/2025-07-01/connections/" + tt.connectionID + "/enable" + expectedPath := APIPathPrefix + "/connections/" + tt.connectionID + "/enable" if r.URL.Path != expectedPath { t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) } @@ -561,7 +561,7 @@ func TestDisableConnection(t *testing.T) { if r.Method != http.MethodPut { t.Errorf("expected PUT request, got %s", r.Method) } - expectedPath := "/2025-07-01/connections/" + tt.connectionID + "/disable" + expectedPath := APIPathPrefix + "/connections/" + tt.connectionID + "/disable" if r.URL.Path != expectedPath { t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) } @@ -640,7 +640,7 @@ func TestPauseConnection(t *testing.T) { if r.Method != http.MethodPut { t.Errorf("expected PUT request, got %s", r.Method) } - expectedPath := "/2025-07-01/connections/" + tt.connectionID + "/pause" + expectedPath := APIPathPrefix + "/connections/" + tt.connectionID + "/pause" if r.URL.Path != expectedPath { t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) } @@ -719,7 +719,7 @@ func TestUnpauseConnection(t *testing.T) { if r.Method != http.MethodPut { t.Errorf("expected PUT request, got %s", r.Method) } - expectedPath := "/2025-07-01/connections/" + tt.connectionID + "/unpause" + expectedPath := APIPathPrefix + "/connections/" + tt.connectionID + "/unpause" if r.URL.Path != expectedPath { t.Errorf("expected path %s, got %s", expectedPath, r.URL.Path) } @@ -805,8 +805,8 @@ func TestCountConnections(t *testing.T) { if r.Method != http.MethodGet { t.Errorf("expected GET request, got %s", r.Method) } - if r.URL.Path != "/2025-07-01/connections/count" { - t.Errorf("expected path /2025-07-01/connections/count, got %s", r.URL.Path) + if r.URL.Path != APIPathPrefix+"/connections/count" { + t.Errorf("expected path %s/connections/count, got %s", APIPathPrefix, r.URL.Path) } w.WriteHeader(tt.mockStatusCode) diff --git a/pkg/hookdeck/destinations.go b/pkg/hookdeck/destinations.go index ea00da4..066562c 100644 --- a/pkg/hookdeck/destinations.go +++ b/pkg/hookdeck/destinations.go @@ -2,6 +2,7 @@ package hookdeck import ( "context" + "encoding/json" "fmt" "net/url" "time" @@ -58,14 +59,39 @@ func (d *Destination) SetCLIPath(path string) { } } -// GetDestination retrieves a single destination by ID -func (c *Client) GetDestination(ctx context.Context, id string, params map[string]string) (*Destination, error) { +// ListDestinations retrieves a list of destinations with optional filters +func (c *Client) ListDestinations(ctx context.Context, params map[string]string) (*DestinationListResponse, error) { queryParams := url.Values{} for k, v := range params { queryParams.Add(k, v) } - resp, err := c.Get(ctx, fmt.Sprintf("/2025-07-01/destinations/%s", id), queryParams.Encode(), nil) + resp, err := c.Get(ctx, APIPathPrefix+"/destinations", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result DestinationListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse destination list response: %w", err) + } + + return &result, nil +} + +// GetDestination retrieves a single destination by ID +func (c *Client) GetDestination(ctx context.Context, id string, params map[string]string) (*Destination, error) { + queryStr := "" + if len(params) > 0 { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + queryStr = queryParams.Encode() + } + + resp, err := c.Get(ctx, APIPathPrefix+"/destinations/"+id, queryStr, nil) if err != nil { return nil, err } @@ -79,6 +105,139 @@ func (c *Client) GetDestination(ctx context.Context, id string, params map[strin return &destination, nil } +// CreateDestination creates a new destination +func (c *Client) CreateDestination(ctx context.Context, req *DestinationCreateRequest) (*Destination, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal destination request: %w", err) + } + + resp, err := c.Post(ctx, APIPathPrefix+"/destinations", data, nil) + if err != nil { + return nil, err + } + + var destination Destination + _, err = postprocessJsonResponse(resp, &destination) + if err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// UpsertDestination creates or updates a destination by name +func (c *Client) UpsertDestination(ctx context.Context, req *DestinationCreateRequest) (*Destination, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal destination upsert request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/destinations", data, nil) + if err != nil { + return nil, err + } + + var destination Destination + _, err = postprocessJsonResponse(resp, &destination) + if err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// UpdateDestination updates an existing destination by ID +func (c *Client) UpdateDestination(ctx context.Context, id string, req *DestinationUpdateRequest) (*Destination, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal destination update request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/destinations/"+id, data, nil) + if err != nil { + return nil, err + } + + var destination Destination + _, err = postprocessJsonResponse(resp, &destination) + if err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// DeleteDestination deletes a destination +func (c *Client) DeleteDestination(ctx context.Context, id string) error { + urlPath := APIPathPrefix + "/destinations/" + id + req, err := c.newRequest(ctx, "DELETE", urlPath, nil) + if err != nil { + return err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// EnableDestination enables a destination +func (c *Client) EnableDestination(ctx context.Context, id string) (*Destination, error) { + resp, err := c.Put(ctx, APIPathPrefix+"/destinations/"+id+"/enable", []byte("{}"), nil) + if err != nil { + return nil, err + } + + var destination Destination + _, err = postprocessJsonResponse(resp, &destination) + if err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// DisableDestination disables a destination +func (c *Client) DisableDestination(ctx context.Context, id string) (*Destination, error) { + resp, err := c.Put(ctx, APIPathPrefix+"/destinations/"+id+"/disable", []byte("{}"), nil) + if err != nil { + return nil, err + } + + var destination Destination + _, err = postprocessJsonResponse(resp, &destination) + if err != nil { + return nil, fmt.Errorf("failed to parse destination response: %w", err) + } + + return &destination, nil +} + +// CountDestinations counts destinations matching the given filters +func (c *Client) CountDestinations(ctx context.Context, params map[string]string) (*DestinationCountResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/destinations/count", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result DestinationCountResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse destination count response: %w", err) + } + + return &result, nil +} + // DestinationCreateInput represents input for creating a destination inline type DestinationCreateInput struct { Name string `json:"name"` @@ -87,10 +246,31 @@ type DestinationCreateInput struct { Config map[string]interface{} `json:"config,omitempty"` } -// DestinationCreateRequest represents the request to create a destination +// DestinationCreateRequest is the request body for create and upsert (POST/PUT /destinations). +// API requires name. Type and Config are used for HTTP/CLI/MOCK_API destinations. type DestinationCreateRequest struct { Name string `json:"name"` Description *string `json:"description,omitempty"` - URL *string `json:"url,omitempty"` + Type string `json:"type,omitempty"` Config map[string]interface{} `json:"config,omitempty"` } + +// DestinationUpdateRequest is the request body for update (PUT /destinations/{id}). +// API has no required fields; only include fields that are being updated. +type DestinationUpdateRequest struct { + Name string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// DestinationListResponse represents the response from listing destinations +type DestinationListResponse struct { + Models []Destination `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// DestinationCountResponse represents the response from counting destinations +type DestinationCountResponse struct { + Count int `json:"count"` +} diff --git a/pkg/hookdeck/events.go b/pkg/hookdeck/events.go index 5ef0397..7cd31b8 100644 --- a/pkg/hookdeck/events.go +++ b/pkg/hookdeck/events.go @@ -3,16 +3,126 @@ package hookdeck import ( "context" "fmt" + "io" + "net/url" + "time" ) -// RetryEvent retries an event by ID -func (c *Client) RetryEvent(eventID string) error { - retryURL := fmt.Sprintf("/2025-07-01/events/%s/retry", eventID) - resp, err := c.Post(context.Background(), retryURL, []byte("{}"), nil) +// Event represents a Hookdeck event (processed webhook delivery) +type Event struct { + ID string `json:"id"` + Status string `json:"status"` + WebhookID string `json:"webhook_id"` + SourceID string `json:"source_id"` + DestinationID string `json:"destination_id"` + RequestID string `json:"request_id"` + Attempts int `json:"attempts"` + ResponseStatus *int `json:"response_status,omitempty"` + ErrorCode *string `json:"error_code,omitempty"` + CliID *string `json:"cli_id,omitempty"` + EventDataID *string `json:"event_data_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + SuccessfulAt *time.Time `json:"successful_at,omitempty"` + LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` + NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` + Data *EventData `json:"data,omitempty"` + TeamID string `json:"team_id"` +} + +// EventData holds optional request snapshot on the event +type EventData struct { + Headers map[string]interface{} `json:"headers,omitempty"` + Body interface{} `json:"body,omitempty"` + Path string `json:"path,omitempty"` + ParsedQuery map[string]interface{} `json:"parsed_query,omitempty"` +} + +// EventListResponse is the response from listing events +type EventListResponse struct { + Models []Event `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// ListEvents retrieves events with optional filters (params: webhook_id, status, source_id, destination_id, limit, order_by, dir, next, prev, etc.) +func (c *Client) ListEvents(ctx context.Context, params map[string]string) (*EventListResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + resp, err := c.Get(ctx, APIPathPrefix+"/events", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + var result EventListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse event list response: %w", err) + } + return &result, nil +} + +// GetEvent retrieves a single event by ID +func (c *Client) GetEvent(ctx context.Context, id string, params map[string]string) (*Event, error) { + queryStr := "" + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + q.Add(k, v) + } + queryStr = q.Encode() + } + resp, err := c.Get(ctx, APIPathPrefix+"/events/"+id, queryStr, nil) + if err != nil { + return nil, err + } + var event Event + _, err = postprocessJsonResponse(resp, &event) + if err != nil { + return nil, fmt.Errorf("failed to parse event response: %w", err) + } + return &event, nil +} + +// RetryEvent retries an event by ID (POST /events/{id}/retry; no request body) +func (c *Client) RetryEvent(ctx context.Context, eventID string) error { + resp, err := c.Post(ctx, APIPathPrefix+"/events/"+eventID+"/retry", []byte("{}"), nil) + if err != nil { + return err + } + defer resp.Body.Close() + return checkAndPrintError(resp) +} + +// CancelEvent cancels an event by ID (PUT /events/{id}/cancel; no request body) +func (c *Client) CancelEvent(ctx context.Context, eventID string) error { + resp, err := c.Put(ctx, APIPathPrefix+"/events/"+eventID+"/cancel", []byte("{}"), nil) + if err != nil { + return err + } + defer resp.Body.Close() + return checkAndPrintError(resp) +} + +// MuteEvent mutes an event by ID (PUT /events/{id}/mute; no request body) +func (c *Client) MuteEvent(ctx context.Context, eventID string) error { + resp, err := c.Put(ctx, APIPathPrefix+"/events/"+eventID+"/mute", []byte("{}"), nil) if err != nil { return err } defer resp.Body.Close() + return checkAndPrintError(resp) +} - return nil +// GetEventRawBody returns the raw body of an event (GET /events/{id}/raw_body) +func (c *Client) GetEventRawBody(ctx context.Context, eventID string) ([]byte, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/events/"+eventID+"/raw_body", "", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if err := checkAndPrintError(resp); err != nil { + return nil, err + } + return io.ReadAll(resp.Body) } diff --git a/pkg/hookdeck/guest.go b/pkg/hookdeck/guest.go index 40b4801..482e863 100644 --- a/pkg/hookdeck/guest.go +++ b/pkg/hookdeck/guest.go @@ -24,7 +24,7 @@ func (c *Client) CreateGuestUser(input CreateGuestUserInput) (GuestUser, error) if err != nil { return GuestUser{}, err } - res, err := c.Post(context.Background(), "/2025-07-01/cli/guest", input_bytes, nil) + res, err := c.Post(context.Background(), APIPathPrefix+"/cli/guest", input_bytes, nil) if err != nil { return GuestUser{}, err } diff --git a/pkg/hookdeck/projects.go b/pkg/hookdeck/projects.go index cfacd58..3fd9ce2 100644 --- a/pkg/hookdeck/projects.go +++ b/pkg/hookdeck/projects.go @@ -13,7 +13,7 @@ type Project struct { } func (c *Client) ListProjects() ([]Project, error) { - res, err := c.Get(context.Background(), "/2025-07-01/teams", "", nil) + res, err := c.Get(context.Background(), APIPathPrefix+"/teams", "", nil) if err != nil { return []Project{}, err } diff --git a/pkg/hookdeck/requests.go b/pkg/hookdeck/requests.go new file mode 100644 index 0000000..1a98928 --- /dev/null +++ b/pkg/hookdeck/requests.go @@ -0,0 +1,160 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "time" +) + +// Request represents a raw inbound webhook received by a source +type Request struct { + ID string `json:"id"` + SourceID string `json:"source_id"` + Verified bool `json:"verified"` + RejectionCause *string `json:"rejection_cause,omitempty"` + EventsCount int `json:"events_count"` + CliEventsCount int `json:"cli_events_count"` + IgnoredCount int `json:"ignored_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + IngestedAt *time.Time `json:"ingested_at,omitempty"` + OriginalEventDataID *string `json:"original_event_data_id,omitempty"` + Data *RequestData `json:"data,omitempty"` + TeamID string `json:"team_id"` +} + +// RequestData holds optional request snapshot +type RequestData struct { + Headers map[string]interface{} `json:"headers,omitempty"` + Body interface{} `json:"body,omitempty"` + Path string `json:"path,omitempty"` + ParsedQuery map[string]interface{} `json:"parsed_query,omitempty"` +} + +// RequestListResponse is the response from listing requests +type RequestListResponse struct { + Models []Request `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// RequestRetryRequest is the body for POST /requests/{id}/retry. WebhookIDs limits retry to those connections; omit or empty for all. +type RequestRetryRequest struct { + WebhookIDs []string `json:"webhook_ids,omitempty"` +} + +// ListRequests retrieves requests with optional filters +func (c *Client) ListRequests(ctx context.Context, params map[string]string) (*RequestListResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + resp, err := c.Get(ctx, APIPathPrefix+"/requests", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + var result RequestListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse request list response: %w", err) + } + return &result, nil +} + +// GetRequest retrieves a single request by ID +func (c *Client) GetRequest(ctx context.Context, id string, params map[string]string) (*Request, error) { + queryStr := "" + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + q.Add(k, v) + } + queryStr = q.Encode() + } + resp, err := c.Get(ctx, APIPathPrefix+"/requests/"+id, queryStr, nil) + if err != nil { + return nil, err + } + var req Request + _, err = postprocessJsonResponse(resp, &req) + if err != nil { + return nil, fmt.Errorf("failed to parse request response: %w", err) + } + return &req, nil +} + +// RetryRequest retries a request by ID. Pass nil or empty WebhookIDs to retry on all connections; otherwise only for the given connection IDs. +func (c *Client) RetryRequest(ctx context.Context, requestID string, body *RequestRetryRequest) error { + if body == nil { + body = &RequestRetryRequest{} + } + data, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request retry body: %w", err) + } + resp, err := c.Post(ctx, APIPathPrefix+"/requests/"+requestID+"/retry", data, nil) + if err != nil { + return err + } + defer resp.Body.Close() + return checkAndPrintError(resp) +} + +// GetRequestEvents returns the list of events for a request (GET /requests/{id}/events) +func (c *Client) GetRequestEvents(ctx context.Context, requestID string, params map[string]string) (*EventListResponse, error) { + queryStr := "" + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + q.Add(k, v) + } + queryStr = q.Encode() + } + resp, err := c.Get(ctx, APIPathPrefix+"/requests/"+requestID+"/events", queryStr, nil) + if err != nil { + return nil, err + } + var result EventListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse request events response: %w", err) + } + return &result, nil +} + +// GetRequestIgnoredEvents returns the list of ignored events for a request (GET /requests/{id}/ignored_events) +func (c *Client) GetRequestIgnoredEvents(ctx context.Context, requestID string, params map[string]string) (*EventListResponse, error) { + queryStr := "" + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + q.Add(k, v) + } + queryStr = q.Encode() + } + resp, err := c.Get(ctx, APIPathPrefix+"/requests/"+requestID+"/ignored_events", queryStr, nil) + if err != nil { + return nil, err + } + var result EventListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse request ignored events response: %w", err) + } + return &result, nil +} + +// GetRequestRawBody returns the raw body of a request (GET /requests/{id}/raw_body) +func (c *Client) GetRequestRawBody(ctx context.Context, requestID string) ([]byte, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/requests/"+requestID+"/raw_body", "", nil) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if err := checkAndPrintError(resp); err != nil { + return nil, err + } + return io.ReadAll(resp.Body) +} diff --git a/pkg/hookdeck/session.go b/pkg/hookdeck/session.go index 3c86086..75f0326 100644 --- a/pkg/hookdeck/session.go +++ b/pkg/hookdeck/session.go @@ -29,7 +29,7 @@ func (c *Client) CreateSession(input CreateSessionInput) (Session, error) { if err != nil { return Session{}, err } - res, err := c.Post(context.Background(), "/2025-07-01/cli-sessions", input_bytes, nil) + res, err := c.Post(context.Background(), APIPathPrefix+"/cli-sessions", input_bytes, nil) if err != nil { return Session{}, err } diff --git a/pkg/hookdeck/sources.go b/pkg/hookdeck/sources.go index aa0219e..36aee79 100644 --- a/pkg/hookdeck/sources.go +++ b/pkg/hookdeck/sources.go @@ -1,6 +1,10 @@ package hookdeck import ( + "context" + "encoding/json" + "fmt" + "net/url" "time" ) @@ -17,7 +21,9 @@ type Source struct { CreatedAt time.Time `json:"created_at"` } -// SourceCreateInput represents input for creating a source inline +// SourceCreateInput is the payload for a source when nested inside another request +// (e.g. ConnectionCreateRequest.Source). Single responsibility: inline source definition. +// Source has type and config.auth (same shape as standalone source create). type SourceCreateInput struct { Name string `json:"name"` Type string `json:"type"` @@ -25,10 +31,210 @@ type SourceCreateInput struct { Config map[string]interface{} `json:"config,omitempty"` } -// SourceCreateRequest represents the request to create a source +// SourceCreateRequest is the request body for create and upsert (POST/PUT /sources). +// API requires name for both. Same shape as SourceCreateInput but for direct /sources endpoints. type SourceCreateRequest struct { Name string `json:"name"` Description *string `json:"description,omitempty"` Type string `json:"type,omitempty"` Config map[string]interface{} `json:"config,omitempty"` } + +// SourceUpdateRequest is the request body for update (PUT /sources/{id}). +// API has no required fields; only include fields that are being updated. +type SourceUpdateRequest struct { + Name string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + Config map[string]interface{} `json:"config,omitempty"` +} + +// SourceListResponse represents the response from listing sources +type SourceListResponse struct { + Models []Source `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// SourceCountResponse represents the response from counting sources +type SourceCountResponse struct { + Count int `json:"count"` +} + +// ListSources retrieves a list of sources with optional filters +func (c *Client) ListSources(ctx context.Context, params map[string]string) (*SourceListResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/sources", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result SourceListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse source list response: %w", err) + } + + return &result, nil +} + +// GetSource retrieves a single source by ID +func (c *Client) GetSource(ctx context.Context, id string, params map[string]string) (*Source, error) { + queryStr := "" + if len(params) > 0 { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + queryStr = queryParams.Encode() + } + + resp, err := c.Get(ctx, APIPathPrefix+"/sources/"+id, queryStr, nil) + if err != nil { + return nil, err + } + + var source Source + _, err = postprocessJsonResponse(resp, &source) + if err != nil { + return nil, fmt.Errorf("failed to parse source response: %w", err) + } + + return &source, nil +} + +// CreateSource creates a new source +func (c *Client) CreateSource(ctx context.Context, req *SourceCreateRequest) (*Source, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal source request: %w", err) + } + + resp, err := c.Post(ctx, APIPathPrefix+"/sources", data, nil) + if err != nil { + return nil, err + } + + var source Source + _, err = postprocessJsonResponse(resp, &source) + if err != nil { + return nil, fmt.Errorf("failed to parse source response: %w", err) + } + + return &source, nil +} + +// UpsertSource creates or updates a source by name +func (c *Client) UpsertSource(ctx context.Context, req *SourceCreateRequest) (*Source, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal source upsert request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/sources", data, nil) + if err != nil { + return nil, err + } + + var source Source + _, err = postprocessJsonResponse(resp, &source) + if err != nil { + return nil, fmt.Errorf("failed to parse source response: %w", err) + } + + return &source, nil +} + +// UpdateSource updates an existing source by ID +func (c *Client) UpdateSource(ctx context.Context, id string, req *SourceUpdateRequest) (*Source, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal source update request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/sources/"+id, data, nil) + if err != nil { + return nil, err + } + + var source Source + _, err = postprocessJsonResponse(resp, &source) + if err != nil { + return nil, fmt.Errorf("failed to parse source response: %w", err) + } + + return &source, nil +} + +// DeleteSource deletes a source +func (c *Client) DeleteSource(ctx context.Context, id string) error { + urlPath := APIPathPrefix + "/sources/" + id + req, err := c.newRequest(ctx, "DELETE", urlPath, nil) + if err != nil { + return err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// EnableSource enables a source +func (c *Client) EnableSource(ctx context.Context, id string) (*Source, error) { + resp, err := c.Put(ctx, APIPathPrefix+"/sources/"+id+"/enable", []byte("{}"), nil) + if err != nil { + return nil, err + } + + var source Source + _, err = postprocessJsonResponse(resp, &source) + if err != nil { + return nil, fmt.Errorf("failed to parse source response: %w", err) + } + + return &source, nil +} + +// DisableSource disables a source +func (c *Client) DisableSource(ctx context.Context, id string) (*Source, error) { + resp, err := c.Put(ctx, APIPathPrefix+"/sources/"+id+"/disable", []byte("{}"), nil) + if err != nil { + return nil, err + } + + var source Source + _, err = postprocessJsonResponse(resp, &source) + if err != nil { + return nil, fmt.Errorf("failed to parse source response: %w", err) + } + + return &source, nil +} + +// CountSources counts sources matching the given filters +func (c *Client) CountSources(ctx context.Context, params map[string]string) (*SourceCountResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/sources/count", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result SourceCountResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse source count response: %w", err) + } + + return &result, nil +} diff --git a/pkg/hookdeck/transformations.go b/pkg/hookdeck/transformations.go new file mode 100644 index 0000000..02343d0 --- /dev/null +++ b/pkg/hookdeck/transformations.go @@ -0,0 +1,283 @@ +package hookdeck + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "time" +) + +// Transformation represents a Hookdeck transformation +type Transformation struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Env map[string]string `json:"env,omitempty"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` +} + +// TransformationCreateRequest is the request body for create and upsert (POST/PUT /transformations). +// API requires name and code for both. +type TransformationCreateRequest struct { + Name string `json:"name"` + Code string `json:"code"` + Env map[string]string `json:"env,omitempty"` +} + +// TransformationUpdateRequest is the request body for update (PUT /transformations/{id}). +// API supports partial update; only include fields that are being updated. +type TransformationUpdateRequest struct { + Name string `json:"name,omitempty"` + Code string `json:"code,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// TransformationListResponse represents the response from listing transformations +type TransformationListResponse struct { + Models []Transformation `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// TransformationCountResponse represents the response from counting transformations +type TransformationCountResponse struct { + Count int `json:"count"` +} + +// TransformationRunRequest is the request body for PUT /transformations/run. +// Either Code or TransformationID must be set. Request.Headers is required (can be empty object). +type TransformationRunRequest struct { + Code string `json:"code,omitempty"` + TransformationID string `json:"transformation_id,omitempty"` + WebhookID string `json:"webhook_id,omitempty"` + Env map[string]string `json:"env,omitempty"` + Request *TransformationRunRequestInput `json:"request,omitempty"` +} + +// TransformationRunRequestInput is the "request" object for run (required headers; optional body, path, query). +type TransformationRunRequestInput struct { + Headers map[string]string `json:"headers"` + Body interface{} `json:"body,omitempty"` + Path string `json:"path,omitempty"` + Query string `json:"query,omitempty"` + ParsedQuery map[string]interface{} `json:"parsed_query,omitempty"` +} + +// TransformationRunResponse is the response from PUT /transformations/run. +// Matches OpenAPI schema TransformationExecutorOutput. +type TransformationRunResponse struct { + RequestID string `json:"request_id,omitempty"` + TransformationID string `json:"transformation_id,omitempty"` + ExecutionID string `json:"execution_id,omitempty"` + Request *TransformationRunRequestInput `json:"request,omitempty"` +} + +// TransformationExecution represents a single transformation execution +type TransformationExecution struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + // Additional fields may be present from API +} + +// TransformationExecutionListResponse represents the response from listing executions +type TransformationExecutionListResponse struct { + Models []TransformationExecution `json:"models"` + Pagination PaginationResponse `json:"pagination"` +} + +// ListTransformations retrieves a list of transformations with optional filters +func (c *Client) ListTransformations(ctx context.Context, params map[string]string) (*TransformationListResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/transformations", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result TransformationListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation list response: %w", err) + } + + return &result, nil +} + +// GetTransformation retrieves a single transformation by ID +func (c *Client) GetTransformation(ctx context.Context, id string) (*Transformation, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/transformations/"+id, "", nil) + if err != nil { + return nil, err + } + + var t Transformation + _, err = postprocessJsonResponse(resp, &t) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation response: %w", err) + } + + return &t, nil +} + +// CreateTransformation creates a new transformation +func (c *Client) CreateTransformation(ctx context.Context, req *TransformationCreateRequest) (*Transformation, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal transformation request: %w", err) + } + + resp, err := c.Post(ctx, APIPathPrefix+"/transformations", data, nil) + if err != nil { + return nil, err + } + + var t Transformation + _, err = postprocessJsonResponse(resp, &t) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation response: %w", err) + } + + return &t, nil +} + +// UpsertTransformation creates or updates a transformation by name +func (c *Client) UpsertTransformation(ctx context.Context, req *TransformationCreateRequest) (*Transformation, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal transformation upsert request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/transformations", data, nil) + if err != nil { + return nil, err + } + + var t Transformation + _, err = postprocessJsonResponse(resp, &t) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation response: %w", err) + } + + return &t, nil +} + +// UpdateTransformation updates an existing transformation by ID +func (c *Client) UpdateTransformation(ctx context.Context, id string, req *TransformationUpdateRequest) (*Transformation, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal transformation update request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/transformations/"+id, data, nil) + if err != nil { + return nil, err + } + + var t Transformation + _, err = postprocessJsonResponse(resp, &t) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation response: %w", err) + } + + return &t, nil +} + +// DeleteTransformation deletes a transformation +func (c *Client) DeleteTransformation(ctx context.Context, id string) error { + urlPath := APIPathPrefix + "/transformations/" + id + req, err := c.newRequest(ctx, "DELETE", urlPath, nil) + if err != nil { + return err + } + + resp, err := c.PerformRequest(ctx, req) + if err != nil { + return err + } + defer resp.Body.Close() + + return nil +} + +// CountTransformations counts transformations matching the given filters +func (c *Client) CountTransformations(ctx context.Context, params map[string]string) (*TransformationCountResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/transformations/count", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result TransformationCountResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation count response: %w", err) + } + + return &result, nil +} + +// RunTransformation runs transformation code (test run) via PUT /transformations/run +func (c *Client) RunTransformation(ctx context.Context, req *TransformationRunRequest) (*TransformationRunResponse, error) { + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal transformation run request: %w", err) + } + + resp, err := c.Put(ctx, APIPathPrefix+"/transformations/run", data, nil) + if err != nil { + return nil, err + } + + var result TransformationRunResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation run response: %w", err) + } + + return &result, nil +} + +// ListTransformationExecutions lists executions for a transformation +func (c *Client) ListTransformationExecutions(ctx context.Context, transformationID string, params map[string]string) (*TransformationExecutionListResponse, error) { + queryParams := url.Values{} + for k, v := range params { + queryParams.Add(k, v) + } + + resp, err := c.Get(ctx, APIPathPrefix+"/transformations/"+transformationID+"/executions", queryParams.Encode(), nil) + if err != nil { + return nil, err + } + + var result TransformationExecutionListResponse + _, err = postprocessJsonResponse(resp, &result) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation executions list response: %w", err) + } + + return &result, nil +} + +// GetTransformationExecution retrieves a single execution by transformation ID and execution ID +func (c *Client) GetTransformationExecution(ctx context.Context, transformationID, executionID string) (*TransformationExecution, error) { + resp, err := c.Get(ctx, APIPathPrefix+"/transformations/"+transformationID+"/executions/"+executionID, "", nil) + if err != nil { + return nil, err + } + + var exec TransformationExecution + _, err = postprocessJsonResponse(resp, &exec) + if err != nil { + return nil, fmt.Errorf("failed to parse transformation execution response: %w", err) + } + + return &exec, nil +} diff --git a/pkg/listen/tui/update.go b/pkg/listen/tui/update.go index af440fc..ba289d0 100644 --- a/pkg/listen/tui/update.go +++ b/pkg/listen/tui/update.go @@ -1,6 +1,7 @@ package tui import ( + "context" "os/exec" "runtime" @@ -184,7 +185,7 @@ func (m Model) retrySelectedEvent() tea.Cmd { client := m.client return func() tea.Msg { - err := client.RetryEvent(eventID) + err := client.RetryEvent(context.Background(), eventID) if err != nil { return retryResultMsg{err: err} } diff --git a/test/acceptance/attempt_test.go b/test/acceptance/attempt_test.go new file mode 100644 index 0000000..2b47e55 --- /dev/null +++ b/test/acceptance/attempt_test.go @@ -0,0 +1,107 @@ +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAttemptList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "attempt", "list", "--event-id", eventID) + assert.NotEmpty(t, stdout) +} + +func TestAttemptGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + attempts := pollForAttemptsByEventID(t, cli, eventID) + attemptID := attempts[0].ID + + stdout := cli.RunExpectSuccess("gateway", "attempt", "get", attemptID) + assert.Contains(t, stdout, attemptID) + assert.Contains(t, stdout, eventID) +} + +func TestAttemptListJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + attempts := pollForAttemptsByEventID(t, cli, eventID) + assert.NotEmpty(t, attempts[0].ID) + assert.Equal(t, eventID, attempts[0].EventID) +} + +func TestAttemptListWithOrderBy(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + cli.RunExpectSuccess("gateway", "attempt", "list", "--event-id", eventID, "--order-by", "created_at", "--limit", "5") +} + +func TestAttemptListWithDir(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + cli.RunExpectSuccess("gateway", "attempt", "list", "--event-id", eventID, "--dir", "desc", "--limit", "5") +} + +func TestAttemptListWithLimit(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + cli.RunExpectSuccess("gateway", "attempt", "list", "--event-id", eventID, "--limit", "2") +} + +func TestAttemptListWithNext(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + _, _, err := cli.Run("gateway", "attempt", "list", "--event-id", eventID, "--limit", "1", "--next", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestAttemptListWithPrev(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + _, _, err := cli.Run("gateway", "attempt", "list", "--event-id", eventID, "--limit", "1", "--prev", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} diff --git a/test/acceptance/connection_error_hints_test.go b/test/acceptance/connection_error_hints_test.go index 761c338..7885746 100644 --- a/test/acceptance/connection_error_hints_test.go +++ b/test/acceptance/connection_error_hints_test.go @@ -21,7 +21,7 @@ func TestConnectionCreateWithNonExistentSourceID(t *testing.T) { fakeSourceID := "src_nonexistent123" // Try to create connection with non-existent source ID - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-id", fakeSourceID, "--destination-name", destName, @@ -56,7 +56,7 @@ func TestConnectionCreateWithNonExistentDestinationID(t *testing.T) { fakeDestinationID := "des_nonexistent123" // Try to create connection with non-existent destination ID - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -92,7 +92,7 @@ func TestConnectionCreateWithWrongIDType(t *testing.T) { wrongIDType := "web_y0A7nz0tRxZy" // Try to create connection with wrong ID type - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-id", wrongIDType, "--destination-name", destName, @@ -128,7 +128,7 @@ func TestConnectionUpsertWithNonExistentSourceID(t *testing.T) { fakeSourceID := "src_nonexistent456" // Try to upsert connection with non-existent source ID - stdout, stderr, err := cli.Run("connection", "upsert", connName, + stdout, stderr, err := cli.Run("gateway", "connection", "upsert", connName, "--source-id", fakeSourceID, "--destination-name", destName, "--destination-type", "CLI", @@ -162,7 +162,7 @@ func TestConnectionUpsertWithNonExistentDestinationID(t *testing.T) { fakeDestinationID := "des_nonexistent456" // Try to upsert connection with non-existent destination ID - stdout, stderr, err := cli.Run("connection", "upsert", connName, + stdout, stderr, err := cli.Run("gateway", "connection", "upsert", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", "--destination-id", fakeDestinationID, @@ -198,7 +198,7 @@ func TestConnectionCreateWithExistingSourceID(t *testing.T) { var initialConn Connection err := cli.RunJSON(&initialConn, - "connection", "create", + "gateway", "connection", "create", "--name", initialConnName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -211,7 +211,7 @@ func TestConnectionCreateWithExistingSourceID(t *testing.T) { // Get the source ID from the created connection var connDetails map[string]interface{} - err = cli.RunJSON(&connDetails, "connection", "get", initialConn.ID) + err = cli.RunJSON(&connDetails, "gateway", "connection", "get", initialConn.ID) require.NoError(t, err, "Should get connection details") source, ok := connDetails["source"].(map[string]interface{}) @@ -232,7 +232,7 @@ func TestConnectionCreateWithExistingSourceID(t *testing.T) { var newConn Connection err = cli.RunJSON(&newConn, - "connection", "create", + "gateway", "connection", "create", "--name", newConnName, "--source-id", sourceID, "--destination-name", newDestName, diff --git a/test/acceptance/connection_list_test.go b/test/acceptance/connection_list_test.go index a8a6b02..1ef8978 100644 --- a/test/acceptance/connection_list_test.go +++ b/test/acceptance/connection_list_test.go @@ -30,7 +30,7 @@ func TestConnectionListFilters(t *testing.T) { // Create a connection var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -47,7 +47,7 @@ func TestConnectionListFilters(t *testing.T) { }) // Verify connection is NOT in disabled list - stdout, stderr, err := cli.Run("connection", "list", "--disabled", "--output", "json") + stdout, stderr, err := cli.Run("gateway", "connection", "list", "--disabled", "--output", "json") require.NoError(t, err, "Should list disabled connections: stderr=%s", stderr) var disabledConns []Connection @@ -66,11 +66,11 @@ func TestConnectionListFilters(t *testing.T) { assert.True(t, found, "Active connection should appear when --disabled flag is used (inclusive filtering)") // Disable the connection - _, stderr, err = cli.Run("connection", "disable", conn.ID) + _, stderr, err = cli.Run("gateway", "connection", "disable", conn.ID) require.NoError(t, err, "Should disable connection: stderr=%s", stderr) // Verify connection IS in disabled list - stdout, stderr, err = cli.Run("connection", "list", "--disabled", "--output", "json") + stdout, stderr, err = cli.Run("gateway", "connection", "list", "--disabled", "--output", "json") require.NoError(t, err, "Should list disabled connections: stderr=%s", stderr) err = json.Unmarshal([]byte(stdout), &disabledConns) @@ -87,7 +87,7 @@ func TestConnectionListFilters(t *testing.T) { assert.True(t, found, "Disabled connection should appear when filtering for disabled connections") // Verify connection is NOT in default list (without --disabled flag) - stdout, stderr, err = cli.Run("connection", "list", "--output", "json") + stdout, stderr, err = cli.Run("gateway", "connection", "list", "--output", "json") require.NoError(t, err, "Should list connections: stderr=%s", stderr) var activeConns []Connection @@ -122,7 +122,7 @@ func TestConnectionListFilters(t *testing.T) { // Create a connection with a unique name var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -139,7 +139,7 @@ func TestConnectionListFilters(t *testing.T) { }) // Filter by exact name - stdout, stderr, err := cli.Run("connection", "list", "--name", connName, "--output", "json") + stdout, stderr, err := cli.Run("gateway", "connection", "list", "--name", connName, "--output", "json") require.NoError(t, err, "Should filter by name: stderr=%s", stderr) var filteredConns []Connection @@ -175,7 +175,7 @@ func TestConnectionListFilters(t *testing.T) { // Create a connection var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -188,7 +188,7 @@ func TestConnectionListFilters(t *testing.T) { // Get source ID from the created connection var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", conn.ID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should get connection details") source, ok := getResp["source"].(map[string]interface{}) @@ -202,7 +202,7 @@ func TestConnectionListFilters(t *testing.T) { }) // Filter by source ID - stdout, stderr, err := cli.Run("connection", "list", "--source-id", sourceID, "--output", "json") + stdout, stderr, err := cli.Run("gateway", "connection", "list", "--source-id", sourceID, "--output", "json") require.NoError(t, err, "Should filter by source ID: stderr=%s", stderr) var filteredConns []Connection @@ -230,7 +230,7 @@ func TestConnectionListFilters(t *testing.T) { cli := NewCLIRunner(t) // List with limit of 5 - stdout, stderr, err := cli.Run("connection", "list", "--limit", "5", "--output", "json") + stdout, stderr, err := cli.Run("gateway", "connection", "list", "--limit", "5", "--output", "json") require.NoError(t, err, "Should list with limit: stderr=%s", stderr) var conns []Connection @@ -258,7 +258,7 @@ func TestConnectionListFilters(t *testing.T) { // Create a connection to test output format var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -275,7 +275,7 @@ func TestConnectionListFilters(t *testing.T) { }) // List without --output json to get human-readable format - stdout := cli.RunExpectSuccess("connection", "list") + stdout := cli.RunExpectSuccess("gateway", "connection", "list") // Should contain human-readable text assert.True(t, diff --git a/test/acceptance/connection_oauth_aws_test.go b/test/acceptance/connection_oauth_aws_test.go index b7ce154..d26917d 100644 --- a/test/acceptance/connection_oauth_aws_test.go +++ b/test/acceptance/connection_oauth_aws_test.go @@ -24,7 +24,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with HTTP destination (OAuth2 Client Credentials) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -58,7 +58,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { assert.Equal(t, "OAUTH2_CLIENT_CREDENTIALS", authType, "Auth type should be OAUTH2_CLIENT_CREDENTIALS") // Fetch connection with --include-destination-auth to verify credentials were stored - getStdout, getStderr, getErr := cli.Run("connection", "get", connID, + getStdout, getStderr, getErr := cli.Run("gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") require.NoError(t, getErr, "Failed to get connection: stderr=%s", getStderr) @@ -104,7 +104,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with HTTP destination (OAuth2 Authorization Code) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -139,7 +139,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { assert.Equal(t, "OAUTH2_AUTHORIZATION_CODE", authType, "Auth type should be OAUTH2_AUTHORIZATION_CODE") // Fetch connection with --include-destination-auth to verify credentials were stored - getStdout, getStderr, getErr := cli.Run("connection", "get", connID, + getStdout, getStderr, getErr := cli.Run("gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") require.NoError(t, getErr, "Failed to get connection: stderr=%s", getStderr) @@ -185,7 +185,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with HTTP destination (AWS Signature) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -219,7 +219,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { assert.Equal(t, "AWS_SIGNATURE", authType, "Auth type should be AWS_SIGNATURE") // Fetch connection with --include-destination-auth to verify credentials were stored - getStdout, getStderr, getErr := cli.Run("connection", "get", connID, + getStdout, getStderr, getErr := cli.Run("gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") require.NoError(t, getErr, "Failed to get connection: stderr=%s", getStderr) @@ -269,7 +269,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { // Using a minimal but valid JSON structure for service account key serviceAccountKey := `{"type":"service_account","project_id":"test-project","private_key_id":"test-key-id","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----\n","client_email":"test@test-project.iam.gserviceaccount.com","client_id":"123456789","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token"}` - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -301,7 +301,7 @@ func TestConnectionOAuth2AWSAuthentication(t *testing.T) { assert.Equal(t, "GCP_SERVICE_ACCOUNT", authType, "Auth type should be GCP_SERVICE_ACCOUNT") // Fetch connection with --include-destination-auth to verify credentials were stored - getStdout, getStderr, getErr := cli.Run("connection", "get", connID, + getStdout, getStderr, getErr := cli.Run("gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") require.NoError(t, getErr, "Failed to get connection: stderr=%s", getStderr) diff --git a/test/acceptance/connection_test.go b/test/acceptance/connection_test.go index cc5aa13..6b3ddf5 100644 --- a/test/acceptance/connection_test.go +++ b/test/acceptance/connection_test.go @@ -18,7 +18,7 @@ func TestConnectionListBasic(t *testing.T) { cli := NewCLIRunner(t) // List should work even if there are no connections - stdout := cli.RunExpectSuccess("connection", "list") + stdout := cli.RunExpectSuccess("gateway", "connection", "list") assert.NotEmpty(t, stdout, "connection list should produce output") t.Logf("Connection list output: %s", strings.TrimSpace(stdout)) @@ -43,7 +43,7 @@ func TestConnectionCreateAndDelete(t *testing.T) { // Verify the connection was created by getting it (JSON output) var conn Connection - err := cli.RunJSON(&conn, "connection", "get", connID) + err := cli.RunJSON(&conn, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, conn.ID, "Retrieved connection ID should match") assert.NotEmpty(t, conn.Name, "Connection should have a name") @@ -53,7 +53,7 @@ func TestConnectionCreateAndDelete(t *testing.T) { assert.NotEmpty(t, conn.Destination.Type, "Connection destination should have a type") // Verify human-readable output includes type information - stdout := cli.RunExpectSuccess("connection", "get", connID) + stdout := cli.RunExpectSuccess("gateway", "connection", "get", connID) assert.Contains(t, stdout, "Type:", "Human-readable output should include 'Type:' label") assert.True(t, strings.Contains(stdout, conn.Source.Type) && strings.Contains(stdout, conn.Destination.Type), @@ -78,7 +78,7 @@ func TestConnectionGetByName(t *testing.T) { // Create a test connection var createResp Connection err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -96,13 +96,13 @@ func TestConnectionGetByName(t *testing.T) { // Test 1: Get by ID (original behavior) var getByID Connection - err = cli.RunJSON(&getByID, "connection", "get", createResp.ID) + err = cli.RunJSON(&getByID, "gateway", "connection", "get", createResp.ID) require.NoError(t, err, "Should be able to get connection by ID") assert.Equal(t, createResp.ID, getByID.ID, "Connection ID should match") // Test 2: Get by name (new behavior) var getByName Connection - err = cli.RunJSON(&getByName, "connection", "get", connName) + err = cli.RunJSON(&getByName, "gateway", "connection", "get", connName) require.NoError(t, err, "Should be able to get connection by name") assert.Equal(t, createResp.ID, getByName.ID, "Connection ID should match when retrieved by name") assert.Equal(t, connName, getByName.Name, "Connection name should match") @@ -122,14 +122,14 @@ func TestConnectionGetNotFound(t *testing.T) { cli := NewCLIRunner(t) // Test 1: Non-existent ID - stdout, stderr, err := cli.Run("connection", "get", "conn_nonexistent123") + stdout, stderr, err := cli.Run("gateway", "connection", "get", "conn_nonexistent123") require.Error(t, err, "Should error when connection ID doesn't exist") combinedOutput := stdout + stderr assert.Contains(t, combinedOutput, "connection not found", "Error should indicate connection not found") assert.Contains(t, combinedOutput, "Please check the connection name or ID", "Error should suggest checking the identifier") // Test 2: Non-existent name - stdout, stderr, err = cli.Run("connection", "get", "nonexistent-connection-name-xyz") + stdout, stderr, err = cli.Run("gateway", "connection", "get", "nonexistent-connection-name-xyz") require.Error(t, err, "Should error when connection name doesn't exist") combinedOutput = stdout + stderr assert.Contains(t, combinedOutput, "connection not found", "Error should indicate connection not found") @@ -153,7 +153,7 @@ func TestConnectionWithWebhookSource(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -196,7 +196,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { destName := "test-webhook-dest-" + timestamp // Create connection with WEBHOOK source (no authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -233,7 +233,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") // Compare key fields between create and get responses @@ -269,7 +269,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { webhookSecret := "whsec_test_secret_123" // Create connection with STRIPE source (webhook secret authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "STRIPE", "--source-name", sourceName, @@ -309,7 +309,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, getResp["id"], "Connection ID should match") @@ -338,7 +338,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { apiKey := "test_api_key_abc123" // Create connection with HTTP source (API key authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "HTTP", "--source-name", sourceName, @@ -376,11 +376,23 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, getResp["id"], "Connection ID should match") + // Get with --include-source-auth and verify source config.auth_type is set + var getWithAuthResp map[string]interface{} + err = cli.RunJSON(&getWithAuthResp, "gateway", "connection", "get", connID, "--include-source-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-source-auth should succeed") + srcWithAuth, ok := getWithAuthResp["source"].(map[string]interface{}) + require.True(t, ok, "connection response must include source") + srcConfig, ok := srcWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "source must include config when using --include-source-auth") + authType, ok := srcConfig["auth_type"].(string) + require.True(t, ok && authType != "", "source config must include auth_type when using --include-source-auth") + assert.Equal(t, "API_KEY", authType, "HTTP source with API key should have config.auth_type API_KEY") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -404,7 +416,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { password := "test_pass_123" // Create connection with HTTP source (basic authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "HTTP", "--source-name", sourceName, @@ -449,11 +461,23 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, getResp["id"], "Connection ID should match") + // Get with --include-source-auth and verify source config.auth_type is set + var getWithAuthResp map[string]interface{} + err = cli.RunJSON(&getWithAuthResp, "gateway", "connection", "get", connID, "--include-source-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-source-auth should succeed") + srcWithAuth, ok := getWithAuthResp["source"].(map[string]interface{}) + require.True(t, ok, "connection response must include source") + srcConfig, ok := srcWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "source must include config when using --include-source-auth") + authType, ok := srcConfig["auth_type"].(string) + require.True(t, ok && authType != "", "source config must include auth_type when using --include-source-auth") + assert.Equal(t, "BASIC_AUTH", authType, "HTTP source with basic auth should have config.auth_type BASIC_AUTH") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -476,7 +500,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { hmacSecret := "test_hmac_secret_xyz" // Create connection with TWILIO source (HMAC authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "TWILIO", "--source-name", sourceName, @@ -523,7 +547,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, getResp["id"], "Connection ID should match") @@ -551,7 +575,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { bearerToken := "test_bearer_token_abc123" // Create connection with HTTP destination (bearer token authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -598,7 +622,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, getResp["id"], "Connection ID should match") @@ -611,6 +635,18 @@ func TestConnectionAuthenticationTypes(t *testing.T) { t.Errorf("Expected destination URL in get response config, got: %v", getDestConfig["url"]) } + // Get with --include-destination-auth and verify destination config.auth_type is set + var getWithDestAuthResp map[string]interface{} + err = cli.RunJSON(&getWithDestAuthResp, "gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-destination-auth should succeed") + getDestWithAuth, ok := getWithDestAuthResp["destination"].(map[string]interface{}) + require.True(t, ok, "connection response must include destination") + destConfigWithAuth, ok := getDestWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "destination must include config when using --include-destination-auth") + destAuthType, ok := destConfigWithAuth["auth_type"].(string) + require.True(t, ok && destAuthType != "", "destination config must include auth_type when using --include-destination-auth") + assert.Equal(t, "BEARER_TOKEN", destAuthType, "HTTP destination with bearer auth should have config.auth_type BEARER_TOKEN") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -635,7 +671,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { password := "dest_pass_123" // Create connection with HTTP destination (basic authentication) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -683,7 +719,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connID, getResp["id"], "Connection ID should match") @@ -696,6 +732,18 @@ func TestConnectionAuthenticationTypes(t *testing.T) { t.Errorf("Expected destination URL in get response config, got: %v", getDestConfig["url"]) } + // Get with --include-destination-auth and verify destination config.auth_type is set + var getWithDestAuthResp map[string]interface{} + err = cli.RunJSON(&getWithDestAuthResp, "gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-destination-auth should succeed") + getDestWithAuth, ok := getWithDestAuthResp["destination"].(map[string]interface{}) + require.True(t, ok, "connection response must include destination") + destConfigWithAuth, ok := getDestWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "destination must include config when using --include-destination-auth") + destAuthType, ok := destConfigWithAuth["auth_type"].(string) + require.True(t, ok && destAuthType != "", "destination config must include auth_type when using --include-destination-auth") + assert.Equal(t, "BASIC_AUTH", destAuthType, "HTTP destination with basic auth should have config.auth_type BASIC_AUTH") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -718,7 +766,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { apiKey := "sk_test_123" // Create connection with HTTP destination (API key in header) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -748,6 +796,18 @@ func TestConnectionAuthenticationTypes(t *testing.T) { assert.Equal(t, "API_KEY", destConfig["auth_type"], "Auth type should be API_KEY") + // Get with --include-destination-auth and verify destination config.auth_type is set + var getWithDestAuthResp map[string]interface{} + err = cli.RunJSON(&getWithDestAuthResp, "gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-destination-auth should succeed") + getDestWithAuth, ok := getWithDestAuthResp["destination"].(map[string]interface{}) + require.True(t, ok, "connection response must include destination") + destConfigWithAuth, ok := getDestWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "destination must include config when using --include-destination-auth") + destAuthType, ok := destConfigWithAuth["auth_type"].(string) + require.True(t, ok && destAuthType != "", "destination config must include auth_type when using --include-destination-auth") + assert.Equal(t, "API_KEY", destAuthType, "HTTP destination with API key should have config.auth_type API_KEY") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -771,7 +831,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { apiKey := "sk_test_456" // Create connection with HTTP destination (API key in query) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -801,6 +861,18 @@ func TestConnectionAuthenticationTypes(t *testing.T) { assert.Equal(t, "API_KEY", destConfig["auth_type"], "Auth type should be API_KEY") + // Get with --include-destination-auth and verify destination config.auth_type is set + var getWithDestAuthResp map[string]interface{} + err = cli.RunJSON(&getWithDestAuthResp, "gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-destination-auth should succeed") + getDestWithAuth, ok := getWithDestAuthResp["destination"].(map[string]interface{}) + require.True(t, ok, "connection response must include destination") + destConfigWithAuth, ok := getDestWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "destination must include config when using --include-destination-auth") + destAuthType, ok := destConfigWithAuth["auth_type"].(string) + require.True(t, ok && destAuthType != "", "destination config must include auth_type when using --include-destination-auth") + assert.Equal(t, "API_KEY", destAuthType, "HTTP destination with API key (query) should have config.auth_type API_KEY") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -823,7 +895,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with HTTP destination (custom signature) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -852,6 +924,18 @@ func TestConnectionAuthenticationTypes(t *testing.T) { assert.Equal(t, "CUSTOM_SIGNATURE", destConfig["auth_type"], "Auth type should be CUSTOM_SIGNATURE") + // Get with --include-destination-auth and verify destination config.auth_type is set + var getWithDestAuthResp map[string]interface{} + err = cli.RunJSON(&getWithDestAuthResp, "gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-destination-auth should succeed") + getDestWithAuth, ok := getWithDestAuthResp["destination"].(map[string]interface{}) + require.True(t, ok, "connection response must include destination") + destConfigWithAuth, ok := getDestWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "destination must include config when using --include-destination-auth") + destAuthType, ok := destConfigWithAuth["auth_type"].(string) + require.True(t, ok && destAuthType != "", "destination config must include auth_type when using --include-destination-auth") + assert.Equal(t, "CUSTOM_SIGNATURE", destAuthType, "HTTP destination with custom signature should have config.auth_type CUSTOM_SIGNATURE") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -874,7 +958,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with HTTP destination (Hookdeck signature - explicit) - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -902,6 +986,18 @@ func TestConnectionAuthenticationTypes(t *testing.T) { // Hookdeck signature should be set as the auth type assert.Equal(t, "HOOKDECK_SIGNATURE", destConfig["auth_type"], "Auth type should be HOOKDECK_SIGNATURE") + // Get with --include-destination-auth and verify destination config.auth_type is set + var getWithDestAuthResp map[string]interface{} + err = cli.RunJSON(&getWithDestAuthResp, "gateway", "connection", "get", connID, "--include-destination-auth", "--output", "json") + require.NoError(t, err, "connection get with --include-destination-auth should succeed") + getDestWithAuth, ok := getWithDestAuthResp["destination"].(map[string]interface{}) + require.True(t, ok, "connection response must include destination") + destConfigWithAuth, ok := getDestWithAuth["config"].(map[string]interface{}) + require.True(t, ok, "destination must include config when using --include-destination-auth") + destAuthType, ok := destConfigWithAuth["auth_type"].(string) + require.True(t, ok && destAuthType != "", "destination config must include auth_type when using --include-destination-auth") + assert.Equal(t, "HOOKDECK_SIGNATURE", destAuthType, "HTTP destination with Hookdeck signature should have config.auth_type HOOKDECK_SIGNATURE") + // Cleanup t.Cleanup(func() { deleteConnection(t, cli, connID) @@ -924,7 +1020,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with bearer token auth - stdout, stderr, err := cli.Run("connection", "upsert", connName, + stdout, stderr, err := cli.Run("gateway", "connection", "upsert", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, "--destination-type", "HTTP", @@ -948,7 +1044,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { }) // Update to API key auth - stdout, stderr, err = cli.Run("connection", "upsert", connName, + stdout, stderr, err = cli.Run("gateway", "connection", "upsert", connName, "--destination-auth-method", "api_key", "--destination-api-key", "new_api_key", "--destination-api-key-header", "X-API-Key", @@ -971,7 +1067,7 @@ func TestConnectionAuthenticationTypes(t *testing.T) { assert.Equal(t, "API_KEY", updateDestConfig["auth_type"], "Auth type should be updated to API_KEY") // Update to Hookdeck signature (reset to default) - stdout, stderr, err = cli.Run("connection", "upsert", connName, + stdout, stderr, err = cli.Run("gateway", "connection", "upsert", connName, "--destination-auth-method", "hookdeck", "--output", "json") require.NoError(t, err, "Failed to reset to Hookdeck signature: stderr=%s", stderr) @@ -1009,19 +1105,19 @@ func TestConnectionDelete(t *testing.T) { // Verify the connection exists before deletion var conn Connection - err := cli.RunJSON(&conn, "connection", "get", connID) + err := cli.RunJSON(&conn, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the connection before deletion") assert.Equal(t, connID, conn.ID, "Connection ID should match") // Delete the connection using --force flag (no interactive prompt) - stdout := cli.RunExpectSuccess("connection", "delete", connID, "--force") + stdout := cli.RunExpectSuccess("gateway", "connection", "delete", connID, "--force") assert.NotEmpty(t, stdout, "delete command should produce output") t.Logf("Deleted connection: %s", connID) // Verify deletion by attempting to get the connection // This should fail because the connection no longer exists - stdout, stderr, err := cli.Run("connection", "get", connID, "--output", "json") + stdout, stderr, err := cli.Run("gateway", "connection", "get", connID, "--output", "json") // We expect an error here since the connection was deleted if err == nil { @@ -1064,7 +1160,7 @@ func TestConnectionBulkDelete(t *testing.T) { // Delete all connections using --force flag for i, connID := range connectionIDs { t.Logf("Deleting connection %d/%d: %s", i+1, numConnections, connID) - stdout := cli.RunExpectSuccess("connection", "delete", connID, "--force") + stdout := cli.RunExpectSuccess("gateway", "connection", "delete", connID, "--force") assert.NotEmpty(t, stdout, "delete command should produce output") } @@ -1072,7 +1168,7 @@ func TestConnectionBulkDelete(t *testing.T) { // Verify all connections are deleted for _, connID := range connectionIDs { - _, _, err := cli.Run("connection", "get", connID, "--output", "json") + _, _, err := cli.Run("gateway", "connection", "get", connID, "--output", "json") // We expect an error for each deleted connection if err == nil { @@ -1099,7 +1195,7 @@ func TestConnectionWithRetryRule(t *testing.T) { // Test with linear retry strategy var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1120,7 +1216,7 @@ func TestConnectionWithRetryRule(t *testing.T) { // Verify the rule was created by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotEmpty(t, getConn.Rules, "Connection should have rules") @@ -1150,7 +1246,7 @@ func TestConnectionWithFilterRule(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1170,7 +1266,7 @@ func TestConnectionWithFilterRule(t *testing.T) { // Verify the rule was created by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotEmpty(t, getConn.Rules, "Connection should have rules") @@ -1199,7 +1295,7 @@ func TestConnectionWithTransformRule(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1219,7 +1315,7 @@ func TestConnectionWithTransformRule(t *testing.T) { // Verify the rule was created by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotEmpty(t, getConn.Rules, "Connection should have rules") @@ -1249,7 +1345,7 @@ func TestConnectionWithDelayRule(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1268,7 +1364,7 @@ func TestConnectionWithDelayRule(t *testing.T) { // Verify the rule was created by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotEmpty(t, getConn.Rules, "Connection should have rules") @@ -1296,7 +1392,7 @@ func TestConnectionWithDeduplicateRule(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1316,7 +1412,7 @@ func TestConnectionWithDeduplicateRule(t *testing.T) { // Verify the rule was created by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotEmpty(t, getConn.Rules, "Connection should have rules") @@ -1355,7 +1451,7 @@ func TestConnectionWithMultipleRules(t *testing.T) { // This order matches the API's default ordering for proper data flow through the pipeline. var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1378,7 +1474,7 @@ func TestConnectionWithMultipleRules(t *testing.T) { // Verify the rules were created by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotEmpty(t, getConn.Rules, "Connection should have rules") @@ -1419,7 +1515,7 @@ func TestConnectionWithRateLimiting(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1439,7 +1535,7 @@ func TestConnectionWithRateLimiting(t *testing.T) { // Verify rate limiting configuration by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotNil(t, getConn.Destination, "Connection should have a destination") @@ -1465,7 +1561,7 @@ func TestConnectionWithRateLimiting(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1485,7 +1581,7 @@ func TestConnectionWithRateLimiting(t *testing.T) { // Verify rate limiting configuration by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotNil(t, getConn.Destination, "Connection should have a destination") @@ -1510,7 +1606,7 @@ func TestConnectionWithRateLimiting(t *testing.T) { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1530,7 +1626,7 @@ func TestConnectionWithRateLimiting(t *testing.T) { // Verify rate limiting configuration by getting the connection var getConn Connection - err = cli.RunJSON(&getConn, "connection", "get", conn.ID) + err = cli.RunJSON(&getConn, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") require.NotNil(t, getConn.Destination, "Connection should have a destination") @@ -1567,7 +1663,7 @@ func TestConnectionUpsertCreate(t *testing.T) { // Upsert (create) a new connection var conn Connection err := cli.RunJSON(&conn, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", "--destination-name", destName, @@ -1589,7 +1685,7 @@ func TestConnectionUpsertCreate(t *testing.T) { // SECONDARY: Verify persisted state via GET var fetched Connection - err = cli.RunJSON(&fetched, "connection", "get", conn.ID) + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should be able to get the created connection") assert.Equal(t, connName, fetched.Name, "Connection name should be persisted") @@ -1615,7 +1711,7 @@ func TestConnectionUpsertUpdate(t *testing.T) { // First create a connection var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1633,7 +1729,7 @@ func TestConnectionUpsertUpdate(t *testing.T) { // Now upsert (update) with a description newDesc := "Updated via upsert command" var upserted Connection - err = cli.RunJSON(&upserted, "connection", "upsert", connName, + err = cli.RunJSON(&upserted, "gateway", "connection", "upsert", connName, "--description", newDesc, ) require.NoError(t, err, "Should upsert connection") @@ -1647,7 +1743,7 @@ func TestConnectionUpsertUpdate(t *testing.T) { // SECONDARY: Verify persisted state via GET var fetched Connection - err = cli.RunJSON(&fetched, "connection", "get", conn.ID) + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should get updated connection") assert.Equal(t, newDesc, fetched.Description, "Description should be persisted") @@ -1674,7 +1770,7 @@ func TestConnectionUpsertIdempotent(t *testing.T) { var conn1, conn2 Connection err := cli.RunJSON(&conn1, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", "--destination-name", destName, @@ -1689,7 +1785,7 @@ func TestConnectionUpsertIdempotent(t *testing.T) { }) err = cli.RunJSON(&conn2, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", "--destination-name", destName, @@ -1706,7 +1802,7 @@ func TestConnectionUpsertIdempotent(t *testing.T) { // SECONDARY: Verify persisted state var fetched Connection - err = cli.RunJSON(&fetched, "connection", "get", conn1.ID) + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn1.ID) require.NoError(t, err, "Should get connection") assert.Equal(t, connName, fetched.Name, "Connection name should be persisted") @@ -1727,7 +1823,7 @@ func TestConnectionUpsertDryRun(t *testing.T) { destName := "test-upsert-dryrun-dst-" + timestamp // Run upsert with --dry-run (should not create) - stdout := cli.RunExpectSuccess("connection", "upsert", connName, + stdout := cli.RunExpectSuccess("gateway", "connection", "upsert", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", "--destination-name", destName, @@ -1742,7 +1838,7 @@ func TestConnectionUpsertDryRun(t *testing.T) { // Verify the connection was NOT created by trying to list it var listResp map[string]interface{} - cli.RunJSON(&listResp, "connection", "list", "--name", connName) + cli.RunJSON(&listResp, "gateway", "connection", "list", "--name", connName) // Connection should not exist, so we expect empty or error t.Logf("Successfully verified dry-run for create scenario") @@ -1764,7 +1860,7 @@ func TestConnectionUpsertDryRunUpdate(t *testing.T) { // Create initial connection var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1781,7 +1877,7 @@ func TestConnectionUpsertDryRunUpdate(t *testing.T) { // Run upsert with --dry-run for update newDesc := "This should not be applied" - stdout := cli.RunExpectSuccess("connection", "upsert", connName, + stdout := cli.RunExpectSuccess("gateway", "connection", "upsert", connName, "--description", newDesc, "--dry-run", ) @@ -1792,7 +1888,7 @@ func TestConnectionUpsertDryRunUpdate(t *testing.T) { // Verify the connection was NOT updated var getResp Connection - err = cli.RunJSON(&getResp, "connection", "get", conn.ID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should get connection") assert.NotEqual(t, newDesc, getResp.Description, "Description should not be updated in dry-run") @@ -1817,7 +1913,7 @@ func TestConnectionUpsertPartialUpdate(t *testing.T) { // Create initial connection var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--description", initialDesc, "--source-name", sourceName, @@ -1836,7 +1932,7 @@ func TestConnectionUpsertPartialUpdate(t *testing.T) { // Update only description newDesc := "Updated description only" var upserted Connection - err = cli.RunJSON(&upserted, "connection", "upsert", connName, + err = cli.RunJSON(&upserted, "gateway", "connection", "upsert", connName, "--description", newDesc, ) require.NoError(t, err, "Should upsert connection") @@ -1849,7 +1945,7 @@ func TestConnectionUpsertPartialUpdate(t *testing.T) { // SECONDARY: Verify persisted state via GET var fetched Connection - err = cli.RunJSON(&fetched, "connection", "get", conn.ID) + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should get updated connection") assert.Equal(t, newDesc, fetched.Description, "Description should be persisted") @@ -1875,7 +1971,7 @@ func TestConnectionUpsertWithRules(t *testing.T) { // Create initial connection var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1893,7 +1989,7 @@ func TestConnectionUpsertWithRules(t *testing.T) { // Update with retry rule var upserted Connection err = cli.RunJSON(&upserted, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--rule-retry-strategy", "linear", "--rule-retry-count", "3", "--rule-retry-interval", "5000", @@ -1909,7 +2005,7 @@ func TestConnectionUpsertWithRules(t *testing.T) { // SECONDARY: Verify persisted state via GET var fetched Connection - err = cli.RunJSON(&fetched, "connection", "get", conn.ID) + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should get updated connection") assert.NotEmpty(t, fetched.Rules, "Should have rules persisted") @@ -1932,7 +2028,7 @@ func TestConnectionUpsertReplaceRules(t *testing.T) { // Create initial connection WITH a retry rule var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -1959,7 +2055,7 @@ func TestConnectionUpsertReplaceRules(t *testing.T) { filterBody := `{"type":"payment"}` var upserted Connection err = cli.RunJSON(&upserted, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--rule-filter-body", filterBody, ) require.NoError(t, err, "Should upsert connection with filter rule") @@ -1981,7 +2077,7 @@ func TestConnectionUpsertReplaceRules(t *testing.T) { // SECONDARY: Verify persisted state via GET var fetched Connection - err = cli.RunJSON(&fetched, "connection", "get", conn.ID) + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) require.NoError(t, err, "Should get updated connection") assert.Len(t, fetched.Rules, 1, "Should have exactly one rule persisted") @@ -2002,12 +2098,12 @@ func TestConnectionUpsertValidation(t *testing.T) { timestamp := generateTimestamp() // Test 1: Missing name - _, _, err := cli.Run("connection", "upsert") + _, _, err := cli.Run("gateway", "connection", "upsert") assert.Error(t, err, "Should require name positional argument") // Test 2: Missing required fields for new connection connName := "test-upsert-validation-" + timestamp - _, _, err = cli.Run("connection", "upsert", connName) + _, _, err = cli.Run("gateway", "connection", "upsert", connName) assert.Error(t, err, "Should require source and destination for new connection") t.Logf("Successfully verified validation errors") @@ -2028,7 +2124,7 @@ func TestConnectionCreateOutputStructure(t *testing.T) { // Create connection without --output json to get human-readable format stdout := cli.RunExpectSuccess( - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -2119,7 +2215,7 @@ func TestConnectionWithDestinationPathForwarding(t *testing.T) { destURL := "https://api.hookdeck.com/dev/null" // Create connection with path forwarding disabled and custom HTTP method - stdout, stderr, err := cli.Run("connection", "create", + stdout, stderr, err := cli.Run("gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2165,7 +2261,7 @@ func TestConnectionWithDestinationPathForwarding(t *testing.T) { // Verify using connection get var getResp map[string]interface{} - err = cli.RunJSON(&getResp, "connection", "get", connID) + err = cli.RunJSON(&getResp, "gateway", "connection", "get", connID) require.NoError(t, err, "Should be able to get the created connection") // Verify destination config in get response @@ -2208,7 +2304,7 @@ func TestConnectionWithDestinationPathForwarding(t *testing.T) { var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2262,7 +2358,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Create connection with path forwarding enabled (default) var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2293,7 +2389,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Upsert to disable path forwarding var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--destination-path-forwarding-disabled", "true") require.NoError(t, err, "Failed to upsert connection") @@ -2310,7 +2406,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Upsert again to re-enable path forwarding var upsertResp2 map[string]interface{} err = cli.RunJSON(&upsertResp2, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--destination-path-forwarding-disabled", "false") require.NoError(t, err, "Failed to upsert connection second time") @@ -2343,7 +2439,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Create connection with POST method var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2373,7 +2469,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Upsert to change method to PUT var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--destination-http-method", "PUT") require.NoError(t, err, "Failed to upsert connection") @@ -2404,7 +2500,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Create connection with allowed HTTP methods var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2462,7 +2558,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Create connection with custom response var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2518,7 +2614,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Note: allowed_http_methods and custom_response are only supported for WEBHOOK source types var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2573,7 +2669,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Create connection without allowed methods var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2593,7 +2689,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Upsert to add allowed HTTP methods var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--source-allowed-http-methods", "POST,GET") require.NoError(t, err, "Failed to upsert connection with allowed methods") @@ -2625,7 +2721,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { // Create connection without custom response var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -2646,7 +2742,7 @@ func TestConnectionUpsertDestinationFields(t *testing.T) { customBody := `{"message":"accepted"}` var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--source-custom-response-content-type", "json", "--source-custom-response-body", customBody) require.NoError(t, err, "Failed to upsert connection with custom response") diff --git a/test/acceptance/connection_update_test.go b/test/acceptance/connection_update_test.go new file mode 100644 index 0000000..9d97418 --- /dev/null +++ b/test/acceptance/connection_update_test.go @@ -0,0 +1,346 @@ +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestConnectionUpdateDescription tests updating a connection's description by ID +func TestConnectionUpdateDescription(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + // Update description via gateway path + newDesc := "Updated via connection update test" + var updated Connection + err := cli.RunJSON(&updated, + "gateway", "connection", "update", connID, + "--description", newDesc, + ) + require.NoError(t, err, "Should update connection description") + assert.Equal(t, connID, updated.ID, "Connection ID should match") + assert.Equal(t, newDesc, updated.Description, "Description should be updated") + + // Verify via GET + var fetched Connection + err = cli.RunJSON(&fetched, "gateway", "connection", "get", connID) + require.NoError(t, err, "Should get updated connection") + assert.Equal(t, newDesc, fetched.Description, "Description should be persisted") + + t.Logf("Successfully updated connection description: %s", connID) +} + +// TestConnectionUpdateRename tests renaming a connection by ID +// This is the key use case for update vs upsert -- upsert uses name as identifier +// so cannot rename, but update uses ID and can change the name +func TestConnectionUpdateRename(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-rename-" + timestamp + sourceName := "test-rename-src-" + timestamp + destName := "test-rename-dst-" + timestamp + + // Create connection + var conn Connection + err := cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + ) + require.NoError(t, err, "Should create connection") + require.NotEmpty(t, conn.ID, "Connection should have an ID") + + t.Cleanup(func() { + cli.Run("gateway", "connection", "delete", conn.ID, "--force") + }) + + // Rename via update + newName := "test-renamed-" + timestamp + var updated Connection + err = cli.RunJSON(&updated, + "gateway", "connection", "update", conn.ID, + "--name", newName, + ) + require.NoError(t, err, "Should rename connection") + assert.Equal(t, conn.ID, updated.ID, "Connection ID should be unchanged") + assert.Equal(t, newName, updated.Name, "Name should be updated") + + // Verify via GET + var fetched Connection + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) + require.NoError(t, err, "Should get renamed connection") + assert.Equal(t, newName, fetched.Name, "Name should be persisted") + + // Verify source and destination are preserved + assert.Equal(t, sourceName, fetched.Source.Name, "Source should be preserved after rename") + assert.Equal(t, destName, fetched.Destination.Name, "Destination should be preserved after rename") + + t.Logf("Successfully renamed connection: %s -> %s", connName, newName) +} + +// TestConnectionUpdateRules tests updating rules via the update command +func TestConnectionUpdateRules(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + // Add a retry rule via update + var updated Connection + err := cli.RunJSON(&updated, + "gateway", "connection", "update", connID, + "--rule-retry-strategy", "linear", + "--rule-retry-count", "3", + "--rule-retry-interval", "5000", + ) + require.NoError(t, err, "Should update connection rules") + assert.Equal(t, connID, updated.ID, "Connection ID should match") + require.NotEmpty(t, updated.Rules, "Connection should have rules") + + // Verify via GET + var fetched Connection + err = cli.RunJSON(&fetched, "gateway", "connection", "get", connID) + require.NoError(t, err, "Should get updated connection") + require.NotEmpty(t, fetched.Rules, "Rules should be persisted") + + // Find the retry rule + foundRetry := false + for _, rule := range fetched.Rules { + if rule["type"] == "retry" { + foundRetry = true + assert.Equal(t, "linear", rule["strategy"], "Retry strategy should be linear") + assert.Equal(t, float64(3), rule["count"], "Retry count should be 3") + assert.Equal(t, float64(5000), rule["interval"], "Retry interval should be 5000") + break + } + } + assert.True(t, foundRetry, "Should have a retry rule") + + t.Logf("Successfully updated connection rules: %s", connID) +} + +// TestConnectionUpdateNotFound tests error handling when updating a non-existent connection +func TestConnectionUpdateNotFound(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + _, _, err := cli.Run("gateway", "connection", "update", "web_nonexistent123", + "--description", "This should fail", + ) + require.Error(t, err, "Should fail when connection ID doesn't exist") + + t.Logf("Successfully verified error for non-existent connection update") +} + +// TestConnectionUpdateNoChanges tests that update with no flags shows current state +func TestConnectionUpdateNoChanges(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + // Update with no flags -- should show current state + stdout := cli.RunExpectSuccess("gateway", "connection", "update", connID) + assert.Contains(t, stdout, "No changes specified", "Should indicate no changes") + assert.Contains(t, stdout, connID, "Should show connection ID") + + t.Logf("Successfully verified no-op update: %s", connID) +} + +// TestConnectionUpdateViaRootAlias tests that update works via the root connection alias too +func TestConnectionUpdateViaRootAlias(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + // Update via root alias (hookdeck connection update) + newDesc := "Updated via root alias" + var updated Connection + err := cli.RunJSON(&updated, + "connection", "update", connID, + "--description", newDesc, + ) + require.NoError(t, err, "Should update via root connection alias") + assert.Equal(t, newDesc, updated.Description, "Description should be updated via alias") + + t.Logf("Successfully updated connection via root alias: %s", connID) +} + +// TestConnectionUpdateOutputJSON verifies update with --output json returns valid JSON +func TestConnectionUpdateOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + var updated Connection + err := cli.RunJSON(&updated, + "gateway", "connection", "update", connID, + "--description", "JSON output test", + "--output", "json", + ) + require.NoError(t, err, "Should update with JSON output") + assert.Equal(t, connID, updated.ID, "Response should contain connection ID") + assert.Equal(t, "JSON output test", updated.Description, "Description should be in response") + + t.Logf("Connection update --output json verified: %s", connID) +} + +// TestConnectionUpdateFilterRule verifies update with a filter rule +func TestConnectionUpdateFilterRule(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + filterBody := `{"type":"payment"}` + var updated Connection + err := cli.RunJSON(&updated, + "gateway", "connection", "update", connID, + "--rule-filter-body", filterBody, + ) + require.NoError(t, err, "Should update with filter rule") + require.NotEmpty(t, updated.Rules, "Connection should have rules") + + foundFilter := false + for _, rule := range updated.Rules { + if rule["type"] == "filter" { + foundFilter = true + assert.Equal(t, filterBody, rule["body"], "Filter body should match") + break + } + } + assert.True(t, foundFilter, "Should have a filter rule") + + t.Logf("Connection update with filter rule verified: %s", connID) +} + +// TestConnectionUpdateDelayRule verifies update with a delay rule +func TestConnectionUpdateDelayRule(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + var updated Connection + err := cli.RunJSON(&updated, + "gateway", "connection", "update", connID, + "--rule-delay", "2000", + ) + require.NoError(t, err, "Should update with delay rule") + require.NotEmpty(t, updated.Rules, "Connection should have rules") + + foundDelay := false + for _, rule := range updated.Rules { + if rule["type"] == "delay" { + foundDelay = true + assert.Equal(t, float64(2000), rule["delay"], "Delay should be 2000") + break + } + } + assert.True(t, foundDelay, "Should have a delay rule") + + t.Logf("Connection update with delay rule verified: %s", connID) +} + +// TestConnectionUpdateWithRulesJSON verifies update with --rules JSON string +func TestConnectionUpdateWithRulesJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + rulesJSON := `[{"type":"retry","strategy":"exponential","count":2,"interval":10000}]` + var updated Connection + err := cli.RunJSON(&updated, + "gateway", "connection", "update", connID, + "--rules", rulesJSON, + ) + require.NoError(t, err, "Should update with --rules JSON") + require.Len(t, updated.Rules, 1, "Should have one rule") + assert.Equal(t, "retry", updated.Rules[0]["type"], "Rule type should be retry") + assert.Equal(t, "exponential", updated.Rules[0]["strategy"], "Strategy should be exponential") + + t.Logf("Connection update with --rules JSON verified: %s", connID) +} diff --git a/test/acceptance/connection_upsert_test.go b/test/acceptance/connection_upsert_test.go index dce24be..3a68c2e 100644 --- a/test/acceptance/connection_upsert_test.go +++ b/test/acceptance/connection_upsert_test.go @@ -32,7 +32,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Create initial connection var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -62,7 +62,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Update ONLY the destination URL (this is the bug scenario) var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--destination-url", updatedURL, ) require.NoError(t, err, "Should upsert connection with only destination-url flag") @@ -97,7 +97,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Create initial connection (default HTTP method is POST) var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -118,7 +118,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Update ONLY the HTTP method var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--destination-http-method", "PUT", ) require.NoError(t, err, "Should upsert connection with only http-method flag") @@ -148,7 +148,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Create initial connection without auth var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -169,7 +169,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Update ONLY the auth method var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--destination-auth-method", "bearer", "--destination-bearer-token", "test_token_123", ) @@ -201,7 +201,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Create initial connection var createResp map[string]interface{} err := cli.RunJSON(&createResp, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-type", "WEBHOOK", "--source-name", sourceName, @@ -222,7 +222,7 @@ func TestConnectionUpsertPartialUpdates(t *testing.T) { // Update ONLY source config fields var upsertResp map[string]interface{} err = cli.RunJSON(&upsertResp, - "connection", "upsert", connName, + "gateway", "connection", "upsert", connName, "--source-allowed-http-methods", "POST,PUT", "--source-custom-response-content-type", "json", "--source-custom-response-body", `{"status":"ok"}`, diff --git a/test/acceptance/destination_test.go b/test/acceptance/destination_test.go new file mode 100644 index 0000000..688c5fa --- /dev/null +++ b/test/acceptance/destination_test.go @@ -0,0 +1,408 @@ +package acceptance + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDestinationList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "destination", "list") + assert.NotEmpty(t, stdout) +} + +func TestDestinationCreateAndDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + destID := createTestDestination(t, cli) + t.Cleanup(func() { deleteDestination(t, cli, destID) }) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", destID) + assert.Contains(t, stdout, destID) + assert.Contains(t, stdout, "HTTP") +} + +func TestDestinationGetByName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-get-" + timestamp + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", "--name", name, "--type", "HTTP", "--url", "https://example.com/webhooks") + require.NoError(t, err) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", name) + assert.Contains(t, stdout, dst.ID) + assert.Contains(t, stdout, name) +} + +func TestDestinationCreateWithDescription(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-desc-" + timestamp + desc := "Test destination description" + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", "--name", name, "--type", "HTTP", "--url", "https://example.com/webhooks", "--description", desc) + require.NoError(t, err) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", dst.ID) + assert.Contains(t, stdout, desc) +} + +func TestDestinationUpdate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + destID := createTestDestination(t, cli) + t.Cleanup(func() { deleteDestination(t, cli, destID) }) + + newName := "test-dst-updated-" + generateTimestamp() + cli.RunExpectSuccess("gateway", "destination", "update", destID, "--name", newName) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", destID) + assert.Contains(t, stdout, newName) +} + +func TestDestinationUpsertCreate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-upsert-create-" + generateTimestamp() + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "upsert", name, "--type", "HTTP", "--url", "https://example.com/upsert") + require.NoError(t, err) + require.NotEmpty(t, dst.ID) + assert.Equal(t, name, dst.Name) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) +} + +func TestDestinationUpsertUpdate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-upsert-upd-" + generateTimestamp() + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "upsert", name, "--type", "HTTP", "--url", "https://example.com/webhooks") + require.NoError(t, err) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + newDesc := "Updated via upsert" + err = cli.RunJSON(&dst, "gateway", "destination", "upsert", name, "--description", newDesc) + require.NoError(t, err) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", dst.ID) + assert.Contains(t, stdout, newDesc) +} + +func TestDestinationEnableDisable(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + destID := createTestDestination(t, cli) + t.Cleanup(func() { deleteDestination(t, cli, destID) }) + + cli.RunExpectSuccess("gateway", "destination", "disable", destID) + stdout := cli.RunExpectSuccess("gateway", "destination", "get", destID) + assert.Contains(t, stdout, "disabled") + + cli.RunExpectSuccess("gateway", "destination", "enable", destID) + stdout = cli.RunExpectSuccess("gateway", "destination", "get", destID) + assert.Contains(t, stdout, "active") +} + +func TestDestinationCount(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "destination", "count") + stdout = strings.TrimSpace(stdout) + assert.NotEmpty(t, stdout) + assert.Regexp(t, `^\d+$`, stdout) +} + +func TestDestinationListFilterByName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + destID := createTestDestination(t, cli) + t.Cleanup(func() { deleteDestination(t, cli, destID) }) + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "get", destID) + require.NoError(t, err) + + stdout := cli.RunExpectSuccess("gateway", "destination", "list", "--name", dst.Name) + assert.Contains(t, stdout, dst.ID) + assert.Contains(t, stdout, dst.Name) +} + +func TestDestinationListFilterByType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "destination", "list", "--type", "HTTP", "--limit", "5") + assert.NotContains(t, stdout, "failed") +} + +func TestDestinationDeleteForce(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + destID := createTestDestination(t, cli) + + cli.RunExpectSuccess("gateway", "destination", "delete", destID, "--force") + + _, _, err := cli.Run("gateway", "destination", "get", destID) + require.Error(t, err) +} + +func TestDestinationUpsertDryRun(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-dst-dryrun-" + generateTimestamp() + stdout := cli.RunExpectSuccess("gateway", "destination", "upsert", name, "--type", "HTTP", "--url", "https://example.com", "--dry-run") + assert.Contains(t, stdout, "Dry Run") + assert.Contains(t, stdout, "CREATE") +} + +func TestDestinationGetOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + destID := createTestDestination(t, cli) + t.Cleanup(func() { deleteDestination(t, cli, destID) }) + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "get", destID, "--output", "json") + require.NoError(t, err) + assert.Equal(t, destID, dst.ID) + assert.Equal(t, "HTTP", dst.Type) +} + +func TestDestinationCreateWithBearerToken(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-bearer-" + timestamp + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://api.example.com/webhooks", + "--auth-method", "bearer", + "--bearer-token", "test-token-123", + ) + require.NoError(t, err) + require.NotEmpty(t, dst.ID) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", dst.ID) + assert.Contains(t, stdout, name) + assert.Contains(t, stdout, "HTTP") +} + +// TestDestinationCreateWithAuthThenGetWithIncludeAuth creates a destination with auth (bearer token), +// then gets it with --include-auth. Verifies that config.auth is returned and the token +// set at creation is present in the get output (auth round-trip). +func TestDestinationCreateWithAuthThenGetWithIncludeAuth(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-auth-include-" + timestamp + bearerToken := "test-bearer-roundtrip-secret" + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://api.example.com/webhooks", + "--auth-method", "bearer", + "--bearer-token", bearerToken, + ) + require.NoError(t, err) + require.NotEmpty(t, dst.ID) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + // Get with --include-auth: auth content must be included (include=config.auth). + stdout, _, err := cli.Run("gateway", "destination", "get", dst.ID, "--output", "json", "--include-auth") + require.NoError(t, err) + + if !strings.Contains(stdout, bearerToken) { + t.Logf("Full API response body: %s", stdout) + } + require.Contains(t, stdout, bearerToken, + "get with --include-auth must return auth content; bearer token set at creation should be present in output") + + // When include-auth is used, config must include auth_type (e.g. BEARER_TOKEN for bearer auth) + var getResp map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &getResp), "parse get response as JSON") + config, ok := getResp["config"].(map[string]interface{}) + require.True(t, ok, "get with --include-auth must return config") + authType, hasType := config["auth_type"].(string) + require.True(t, hasType && authType != "", "get with --include-auth must return config.auth_type") + assert.Equal(t, "BEARER_TOKEN", authType, "destination with bearer auth should have config.auth_type BEARER_TOKEN") + t.Logf("Destination config.auth_type: %s", authType) +} + +// TestDestinationCreateWithBasicAuthThenGetWithIncludeAuth creates a destination with basic auth, +// then gets it with --include-auth. Verifies config.auth_type is BASIC_AUTH. +func TestDestinationCreateWithBasicAuthThenGetWithIncludeAuth(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-basic-include-" + timestamp + username := "basic_user" + password := "basic_pass_secret" + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://api.example.com/webhooks", + "--auth-method", "basic", + "--basic-auth-user", username, + "--basic-auth-pass", password, + ) + require.NoError(t, err) + require.NotEmpty(t, dst.ID) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + stdout, _, err := cli.Run("gateway", "destination", "get", dst.ID, "--output", "json", "--include-auth") + require.NoError(t, err) + + require.Contains(t, stdout, username, "get with --include-auth must return auth content (username)") + + var getResp map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &getResp), "parse get response as JSON") + config, ok := getResp["config"].(map[string]interface{}) + require.True(t, ok, "get with --include-auth must return config") + authType, hasType := config["auth_type"].(string) + require.True(t, hasType && authType != "", "get with --include-auth must return config.auth_type") + assert.Equal(t, "BASIC_AUTH", authType, "destination with basic auth should have config.auth_type BASIC_AUTH") + t.Logf("Destination config.auth_type: %s", authType) +} + +// TestDestinationCreateWithAPIKeyThenGetWithIncludeAuth creates a destination with API key auth, +// then gets it with --include-auth. Verifies config.auth_type is API_KEY. +func TestDestinationCreateWithAPIKeyThenGetWithIncludeAuth(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-apikey-include-" + timestamp + apiKey := "test_dst_apikey_secret" + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://api.example.com/webhooks", + "--auth-method", "api_key", + "--api-key", apiKey, + "--api-key-header", "X-API-Key", + ) + require.NoError(t, err) + require.NotEmpty(t, dst.ID) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + stdout, _, err := cli.Run("gateway", "destination", "get", dst.ID, "--output", "json", "--include-auth") + require.NoError(t, err) + + require.Contains(t, stdout, apiKey, "get with --include-auth must return auth content (api_key)") + + var getResp map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &getResp), "parse get response as JSON") + config, ok := getResp["config"].(map[string]interface{}) + require.True(t, ok, "get with --include-auth must return config") + authType, hasType := config["auth_type"].(string) + require.True(t, hasType && authType != "", "get with --include-auth must return config.auth_type") + assert.Equal(t, "API_KEY", authType, "destination with API key auth should have config.auth_type API_KEY") + t.Logf("Destination config.auth_type: %s", authType) +} + +func TestDestinationCreateCLI(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-dst-cli-" + timestamp + + var dst Destination + err := cli.RunJSON(&dst, "gateway", "destination", "create", "--name", name, "--type", "CLI", "--cli-path", "/webhooks") + require.NoError(t, err) + require.NotEmpty(t, dst.ID) + t.Cleanup(func() { deleteDestination(t, cli, dst.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "destination", "get", dst.ID) + assert.Contains(t, stdout, name) + assert.Contains(t, stdout, "CLI") +} + +func TestGatewayDestinationsAliasWorks(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "destinations", "list") + assert.NotContains(t, stdout, "unknown command") +} diff --git a/test/acceptance/event_test.go b/test/acceptance/event_test.go new file mode 100644 index 0000000..abbc70f --- /dev/null +++ b/test/acceptance/event_test.go @@ -0,0 +1,315 @@ +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEventList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "event", "list", "--limit", "5") + assert.NotEmpty(t, stdout) +} + +func TestEventListWithConnectionID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "event", "list", "--connection-id", connID) + assert.Contains(t, stdout, eventID) + assert.Contains(t, stdout, connID) +} + +func TestEventGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "event", "get", eventID) + assert.Contains(t, stdout, eventID) + assert.Contains(t, stdout, connID) +} + +func TestEventRetry(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "event", "retry", eventID) + assert.Contains(t, stdout, "retry requested") +} + +func TestEventCancel(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "event", "cancel", eventID) + assert.Contains(t, stdout, "cancelled") +} + +func TestEventMute(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "event", "mute", eventID) + assert.Contains(t, stdout, "muted") +} + +func TestEventListJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + var events []Event + require.NoError(t, cli.RunJSON(&events, "gateway", "event", "list", "--connection-id", connID, "--limit", "5")) + assert.NotEmpty(t, events) + assert.NotEmpty(t, events[0].ID) + assert.NotEmpty(t, events[0].Status) +} + +func TestEventRawBody(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + stdout := cli.RunExpectSuccess("gateway", "event", "raw-body", eventID) + // We triggered with {"test":true} + assert.Contains(t, stdout, "test") +} + +func TestEventListWithId(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + cli.RunExpectSuccess("gateway", "event", "list", "--id", eventID, "--limit", "5") +} + +func TestEventListWithAttempts(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--attempts", "1", "--limit", "5") +} + +func TestEventListWithResponseStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--response-status", "200", "--limit", "5") +} + +func TestEventListWithErrorCode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--error-code", "TIMEOUT", "--limit", "5") +} + +func TestEventListWithCliID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--cli-id", "cli_xxx", "--limit", "5") +} + +func TestEventListWithIssueID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--issue-id", "iss_xxx", "--limit", "5") +} + +func TestEventListWithCreatedAfter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--created-after", "2020-01-01T00:00:00Z", "--limit", "5") +} + +func TestEventListWithCreatedBefore(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--created-before", "2030-01-01T00:00:00Z", "--limit", "5") +} + +func TestEventListWithSuccessfulAtAfter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--successful-at-after", "2020-01-01T00:00:00Z", "--limit", "5") +} + +func TestEventListWithSuccessfulAtBefore(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--successful-at-before", "2030-01-01T00:00:00Z", "--limit", "5") +} + +func TestEventListWithLastAttemptAtAfter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--last-attempt-at-after", "2020-01-01T00:00:00Z", "--limit", "5") +} + +func TestEventListWithLastAttemptAtBefore(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--last-attempt-at-before", "2030-01-01T00:00:00Z", "--limit", "5") +} + +func TestEventListWithHeaders(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--headers", "{}", "--limit", "5") +} + +func TestEventListWithBody(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--body", "{}", "--limit", "5") +} + +func TestEventListWithPath(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--path", "/webhooks", "--limit", "5") +} + +func TestEventListWithParsedQuery(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--parsed-query", "{}", "--limit", "5") +} + +func TestEventListWithSourceID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + cli.RunExpectSuccess("gateway", "event", "list", "--source-id", conn.Source.ID, "--limit", "5") +} + +func TestEventListWithDestinationID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + cli.RunExpectSuccess("gateway", "event", "list", "--destination-id", conn.Destination.ID, "--limit", "5") +} + +func TestEventListWithStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--status", "SUCCESSFUL", "--limit", "5") +} + +func TestEventListWithOrderBy(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--order-by", "created_at", "--limit", "5") +} + +func TestEventListWithDir(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "event", "list", "--dir", "desc", "--limit", "5") +} + +func TestEventListWithNext(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "event", "list", "--limit", "1", "--next", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestEventListWithPrev(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "event", "list", "--limit", "1", "--prev", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} diff --git a/test/acceptance/gateway_test.go b/test/acceptance/gateway_test.go new file mode 100644 index 0000000..03bcfb7 --- /dev/null +++ b/test/acceptance/gateway_test.go @@ -0,0 +1,302 @@ +package acceptance + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGatewayHelpShowsSubcommands verifies that hookdeck gateway --help lists +// the connection subcommand (and future subcommands as they are added) +func TestGatewayHelpShowsSubcommands(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + stdout := cli.RunExpectSuccess("gateway", "--help") + + // Connection should be listed as a subcommand + assert.Contains(t, stdout, "connection", "gateway --help should list 'connection' subcommand") + assert.Contains(t, stdout, "Commands for managing Event Gateway", "Should show gateway description") + + t.Logf("Gateway help output verified") +} + +// TestGatewayConnectionListWorks verifies that hookdeck gateway connection list +// returns a successful response (same as hookdeck connection list) +func TestGatewayConnectionListWorks(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + // List via gateway path + stdout := cli.RunExpectSuccess("gateway", "connection", "list") + assert.NotEmpty(t, stdout, "gateway connection list should produce output") + + t.Logf("Gateway connection list output: %s", stdout) +} + +// TestGatewayConnectionCreateAndGet verifies full CRUD via the gateway path +func TestGatewayConnectionCreateAndGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-gw-conn-" + timestamp + sourceName := "test-gw-src-" + timestamp + destName := "test-gw-dst-" + timestamp + + // Create via gateway path + var conn Connection + err := cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + ) + require.NoError(t, err, "Should create connection via gateway path") + require.NotEmpty(t, conn.ID, "Connection should have an ID") + + t.Cleanup(func() { + // Delete via gateway path + cli.Run("gateway", "connection", "delete", conn.ID, "--force") + }) + + // Get via gateway path + var fetched Connection + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) + require.NoError(t, err, "Should get connection via gateway path") + assert.Equal(t, conn.ID, fetched.ID, "Connection ID should match") + assert.Equal(t, connName, fetched.Name, "Connection name should match") + + t.Logf("Successfully created and retrieved connection via gateway path: %s", conn.ID) +} + +// TestRootConnectionAliasWorks verifies that the backward-compatible root-level +// hookdeck connection ... still works after adding the gateway namespace +func TestRootConnectionAliasWorks(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-alias-conn-" + timestamp + sourceName := "test-alias-src-" + timestamp + destName := "test-alias-dst-" + timestamp + + // Create via root alias path (hookdeck connection create) + var conn Connection + err := cli.RunJSON(&conn, + "connection", "create", + "--name", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + ) + require.NoError(t, err, "Should create connection via root alias") + require.NotEmpty(t, conn.ID, "Connection should have an ID") + + t.Cleanup(func() { + cli.Run("connection", "delete", conn.ID, "--force") + }) + + // Get via gateway path (cross-path access) + var fetched Connection + err = cli.RunJSON(&fetched, "gateway", "connection", "get", conn.ID) + require.NoError(t, err, "Should get connection via gateway path after creating via alias") + assert.Equal(t, conn.ID, fetched.ID, "Connection ID should match across paths") + + // List via root alias, verify JSON output + stdout, _, err := cli.Run("connection", "list", "--output", "json") + require.NoError(t, err, "Should list via root alias") + + var conns []Connection + err = json.Unmarshal([]byte(stdout), &conns) + require.NoError(t, err, "Should parse JSON list from root alias") + + found := false + for _, c := range conns { + if c.ID == conn.ID { + found = true + break + } + } + assert.True(t, found, "Connection created via alias should appear in list") + + t.Logf("Root connection alias verified: %s", conn.ID) +} + +// TestGatewayConnectionUpsert verifies upsert create and update via gateway path +func TestGatewayConnectionUpsert(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-gw-upsert-" + timestamp + sourceName := "test-gw-upsert-src-" + timestamp + destName := "test-gw-upsert-dst-" + timestamp + + // Upsert create via gateway path + var conn Connection + err := cli.RunJSON(&conn, + "gateway", "connection", "upsert", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + ) + require.NoError(t, err, "Should upsert create via gateway path") + require.NotEmpty(t, conn.ID, "Connection should have an ID") + + t.Cleanup(func() { + cli.Run("gateway", "connection", "delete", conn.ID, "--force") + }) + + // Upsert update (same name) via gateway path + newDesc := "Updated via gateway upsert" + var updated Connection + err = cli.RunJSON(&updated, + "gateway", "connection", "upsert", connName, + "--description", newDesc, + ) + require.NoError(t, err, "Should upsert update via gateway path") + assert.Equal(t, conn.ID, updated.ID, "Connection ID should be unchanged") + assert.Equal(t, newDesc, updated.Description, "Description should be updated") + + t.Logf("Gateway connection upsert verified: %s", conn.ID) +} + +// TestGatewayConnectionDelete verifies delete via gateway path +func TestGatewayConnectionDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + // Delete via gateway path + stdout := cli.RunExpectSuccess("gateway", "connection", "delete", connID, "--force") + assert.NotEmpty(t, stdout, "delete should produce output") + + // Verify connection is gone + _, _, err := cli.Run("gateway", "connection", "get", connID, "--output", "json") + require.Error(t, err, "get should fail after delete") + + t.Logf("Gateway connection delete verified: %s", connID) +} + +// TestGatewayConnectionEnableDisable verifies disable and enable via gateway path +func TestGatewayConnectionEnableDisable(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + connID := createTestConnection(t, cli) + require.NotEmpty(t, connID, "Connection ID should not be empty") + + t.Cleanup(func() { + deleteConnection(t, cli, connID) + }) + + // Disable via gateway path + cli.RunExpectSuccess("gateway", "connection", "disable", connID) + + // Enable via gateway path + cli.RunExpectSuccess("gateway", "connection", "enable", connID) + + t.Logf("Gateway connection enable/disable verified: %s", connID) +} + +// TestGatewayConnectionGetByName verifies get by name via gateway path +func TestGatewayConnectionGetByName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + connName := "test-gw-getbyname-" + timestamp + sourceName := "test-gw-getbyname-src-" + timestamp + destName := "test-gw-getbyname-dst-" + timestamp + + var conn Connection + err := cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + ) + require.NoError(t, err, "Should create connection") + t.Cleanup(func() { cli.Run("gateway", "connection", "delete", conn.ID, "--force") }) + + // Get by name via gateway path + var byName Connection + err = cli.RunJSON(&byName, "gateway", "connection", "get", connName) + require.NoError(t, err, "Should get connection by name") + assert.Equal(t, conn.ID, byName.ID, "Connection ID should match when getting by name") + + t.Logf("Gateway connection get by name verified: %s", connName) +} + +// TestRootConnectionsAliasWorks verifies the plural alias "connections" works +func TestRootConnectionsAliasWorks(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + // "hookdeck connections list" should be rewritten to gateway connection list + stdout := cli.RunExpectSuccess("connections", "list") + assert.NotEmpty(t, stdout, "connections list should produce output") + + t.Logf("Root 'connections' alias verified") +} + +// TestGatewaySourcesAliasWorks verifies the plural alias "sources" works under gateway +func TestGatewaySourcesAliasWorks(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + + // "hookdeck gateway sources list" should behave like "gateway source list" + stdout := cli.RunExpectSuccess("gateway", "sources", "list") + assert.NotEmpty(t, stdout, "gateway sources list should produce output") + + // Help should show source/sources + helpOut := cli.RunExpectSuccess("gateway", "sources", "--help") + assert.Contains(t, helpOut, "source", "gateway sources --help should describe source commands") + + t.Logf("Gateway 'sources' alias verified") +} diff --git a/test/acceptance/helpers.go b/test/acceptance/helpers.go index f59ab45..3af5815 100644 --- a/test/acceptance/helpers.go +++ b/test/acceptance/helpers.go @@ -5,6 +5,7 @@ import ( "bytes" "encoding/json" "fmt" + "net/http" "os" "os/exec" "path/filepath" @@ -207,10 +208,12 @@ type Connection struct { Name string `json:"name"` Description string `json:"description"` Source struct { + ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` } `json:"source"` Destination struct { + ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Config interface{} `json:"config"` @@ -218,6 +221,49 @@ type Connection struct { Rules []map[string]interface{} `json:"rules"` } +// Source represents a Hookdeck source for testing +type Source struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` +} + +// Destination represents a Hookdeck destination for testing +type Destination struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Config interface{} `json:"config"` +} + +// Transformation represents a Hookdeck transformation for testing +type Transformation struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` +} + +// Event represents a Hookdeck event for testing +type Event struct { + ID string `json:"id"` + Status string `json:"status"` + WebhookID string `json:"webhook_id"` +} + +// Request represents a Hookdeck request for testing +type Request struct { + ID string `json:"id"` +} + +// Attempt represents a Hookdeck attempt for testing +type Attempt struct { + ID string `json:"id"` + EventID string `json:"event_id"` + AttemptNumber int `json:"attempt_number"` + Status string `json:"status"` +} + // createTestConnection creates a basic test connection and returns its ID // The connection uses a WEBHOOK source and CLI destination func createTestConnection(t *testing.T, cli *CLIRunner) string { @@ -230,7 +276,7 @@ func createTestConnection(t *testing.T, cli *CLIRunner) string { var conn Connection err := cli.RunJSON(&conn, - "connection", "create", + "gateway", "connection", "create", "--name", connName, "--source-name", sourceName, "--source-type", "WEBHOOK", @@ -246,12 +292,40 @@ func createTestConnection(t *testing.T, cli *CLIRunner) string { return conn.ID } +// createTestConnectionWithMockDestination creates a test connection with a MOCK_API destination. +// Events and attempts are generated by the backend without needing a live CLI. Use this for +// inspection tests (event/request/attempt) that need to trigger and then list/get events. +func createTestConnectionWithMockDestination(t *testing.T, cli *CLIRunner) string { + t.Helper() + + timestamp := generateTimestamp() + connName := fmt.Sprintf("test-conn-%s", timestamp) + sourceName := fmt.Sprintf("test-src-%s", timestamp) + destName := fmt.Sprintf("test-dst-%s", timestamp) + + var conn Connection + err := cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", connName, + "--source-name", sourceName, + "--source-type", "WEBHOOK", + "--destination-name", destName, + "--destination-type", "MOCK_API", + ) + require.NoError(t, err, "Failed to create test connection with mock destination") + require.NotEmpty(t, conn.ID, "Connection ID should not be empty") + + t.Logf("Created test connection (mock dest): %s (ID: %s)", connName, conn.ID) + + return conn.ID +} + // deleteConnection deletes a connection by ID using the --force flag // This is safe to use in cleanup functions and won't prompt for confirmation func deleteConnection(t *testing.T, cli *CLIRunner, id string) { t.Helper() - stdout, stderr, err := cli.Run("connection", "delete", id, "--force") + stdout, stderr, err := cli.Run("gateway", "connection", "delete", id, "--force") if err != nil { // Log but don't fail the test on cleanup errors t.Logf("Warning: Failed to delete connection %s: %v\nstdout: %s\nstderr: %s", @@ -272,6 +346,184 @@ func cleanupConnections(t *testing.T, cli *CLIRunner, ids []string) { } } +// createTestSource creates a WEBHOOK source and returns its ID +func createTestSource(t *testing.T, cli *CLIRunner) string { + t.Helper() + + timestamp := generateTimestamp() + name := fmt.Sprintf("test-src-%s", timestamp) + + var src Source + err := cli.RunJSON(&src, + "gateway", "source", "create", + "--name", name, + "--type", "WEBHOOK", + ) + require.NoError(t, err, "Failed to create test source") + require.NotEmpty(t, src.ID, "Source ID should not be empty") + + t.Logf("Created test source: %s (ID: %s)", name, src.ID) + return src.ID +} + +// deleteSource deletes a source by ID using the --force flag +func deleteSource(t *testing.T, cli *CLIRunner, id string) { + t.Helper() + + stdout, stderr, err := cli.Run("gateway", "source", "delete", id, "--force") + if err != nil { + t.Logf("Warning: Failed to delete source %s: %v\nstdout: %s\nstderr: %s", + id, err, stdout, stderr) + return + } + t.Logf("Deleted source: %s", id) +} + +// createTestDestination creates an HTTP destination with a test URL and returns its ID +func createTestDestination(t *testing.T, cli *CLIRunner) string { + t.Helper() + + timestamp := generateTimestamp() + name := fmt.Sprintf("test-dst-%s", timestamp) + + var dst Destination + err := cli.RunJSON(&dst, + "gateway", "destination", "create", + "--name", name, + "--type", "HTTP", + "--url", "https://example.com/webhooks", + ) + require.NoError(t, err, "Failed to create test destination") + require.NotEmpty(t, dst.ID, "Destination ID should not be empty") + + t.Logf("Created test destination: %s (ID: %s)", name, dst.ID) + return dst.ID +} + +// deleteDestination deletes a destination by ID using the --force flag +func deleteDestination(t *testing.T, cli *CLIRunner, id string) { + t.Helper() + + stdout, stderr, err := cli.Run("gateway", "destination", "delete", id, "--force") + if err != nil { + t.Logf("Warning: Failed to delete destination %s: %v\nstdout: %s\nstderr: %s", + id, err, stdout, stderr) + return + } + t.Logf("Deleted destination: %s", id) +} + +// createTestTransformation creates a transformation with minimal code and returns its ID +func createTestTransformation(t *testing.T, cli *CLIRunner) string { + t.Helper() + + timestamp := generateTimestamp() + name := fmt.Sprintf("test-trn-%s", timestamp) + code := `addHandler("transform", (request, context) => { return request; });` + + var trn Transformation + err := cli.RunJSON(&trn, + "gateway", "transformation", "create", + "--name", name, + "--code", code, + ) + require.NoError(t, err, "Failed to create test transformation") + require.NotEmpty(t, trn.ID, "Transformation ID should not be empty") + + t.Logf("Created test transformation: %s (ID: %s)", name, trn.ID) + return trn.ID +} + +// deleteTransformation deletes a transformation by ID using the --force flag +func deleteTransformation(t *testing.T, cli *CLIRunner, id string) { + t.Helper() + + stdout, stderr, err := cli.Run("gateway", "transformation", "delete", id, "--force") + if err != nil { + t.Logf("Warning: Failed to delete transformation %s: %v\nstdout: %s\nstderr: %s", + id, err, stdout, stderr) + return + } + t.Logf("Deleted transformation: %s", id) +} + +// triggerTestEvent sends a POST request to the given source URL to create a request and event. +// Use after creating a connection; then list events with --connection-id to find the new event. +func triggerTestEvent(t *testing.T, sourceURL string) { + t.Helper() + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(sourceURL, "application/json", strings.NewReader(`{"test":true}`)) + require.NoError(t, err, "POST to source URL failed") + defer resp.Body.Close() + require.True(t, resp.StatusCode >= 200 && resp.StatusCode < 300, + "POST to source URL returned %d", resp.StatusCode) +} + +// createConnectionAndTriggerEvent creates a test connection with a MOCK_API destination (so events +// are generated without a live CLI), triggers one request via the source URL, then polls for the +// event to appear. Returns connection ID and event ID. Caller should cleanup with deleteConnection(t, cli, connID). +func createConnectionAndTriggerEvent(t *testing.T, cli *CLIRunner) (connID, eventID string) { + t.Helper() + + connID = createTestConnectionWithMockDestination(t, cli) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + require.NotEmpty(t, conn.Source.ID, "connection source ID") + + var src Source + require.NoError(t, cli.RunJSON(&src, "gateway", "source", "get", conn.Source.ID)) + require.NotEmpty(t, src.URL, "source URL") + + triggerTestEvent(t, src.URL) + + // Poll for event to appear (API may take a few seconds) + var events []Event + for i := 0; i < 10; i++ { + time.Sleep(2 * time.Second) + require.NoError(t, cli.RunJSON(&events, "gateway", "event", "list", "--connection-id", connID, "--limit", "1")) + if len(events) > 0 { + return connID, events[0].ID + } + } + require.NotEmpty(t, events, "expected at least one event after trigger (waited ~20s)") + return connID, events[0].ID +} + +// pollForRequestsBySourceID polls gateway request list by source ID until at least one request +// appears or the timeout (10 attempts Γ— 2s) is reached. Use after triggering an event when the test +// requires at least one request; fails the test if none appear (no skip). +func pollForRequestsBySourceID(t *testing.T, cli *CLIRunner, sourceID string) []Request { + t.Helper() + var requests []Request + for i := 0; i < 10; i++ { + time.Sleep(2 * time.Second) + require.NoError(t, cli.RunJSON(&requests, "gateway", "request", "list", "--source-id", sourceID, "--limit", "5")) + if len(requests) > 0 { + return requests + } + } + require.NotEmpty(t, requests, "expected at least one request after trigger (waited ~20s)") + return requests +} + +// pollForAttemptsByEventID polls gateway attempt list by event ID until at least one attempt +// appears or the timeout (10 attempts Γ— 2s) is reached. Use after createConnectionAndTriggerEvent +// when the test requires attempts; attempt creation may lag behind event creation. +func pollForAttemptsByEventID(t *testing.T, cli *CLIRunner, eventID string) []Attempt { + t.Helper() + var attempts []Attempt + for i := 0; i < 10; i++ { + time.Sleep(2 * time.Second) + require.NoError(t, cli.RunJSON(&attempts, "gateway", "attempt", "list", "--event-id", eventID, "--limit", "5")) + if len(attempts) > 0 { + return attempts + } + } + require.NotEmpty(t, attempts, "expected at least one attempt after trigger (waited ~20s)") + return attempts +} + // assertContains checks if a string contains a substring func assertContains(t *testing.T, s, substr, msgAndArgs string) { t.Helper() diff --git a/test/acceptance/request_test.go b/test/acceptance/request_test.go new file mode 100644 index 0000000..41e0323 --- /dev/null +++ b/test/acceptance/request_test.go @@ -0,0 +1,336 @@ +package acceptance + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequestList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "request", "list", "--limit", "5") + assert.NotEmpty(t, stdout) +} + +func TestRequestListAndGet(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + // Get connection to find source ID, then poll for requests (ingestion may lag) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + requestID := requests[0].ID + + stdout := cli.RunExpectSuccess("gateway", "request", "get", requestID) + assert.Contains(t, stdout, requestID) +} + +func TestRequestEvents(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, eventID := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + requestID := requests[0].ID + + stdout := cli.RunExpectSuccess("gateway", "request", "events", requestID) + assert.Contains(t, stdout, eventID) +} + +func TestRequestRetry(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + requestID := requests[0].ID + + // Retry is only allowed for rejected requests or those with ignored events. Our request + // succeeded (MOCK_API delivered), so API may return "not eligible for retry". Either outcome is valid. + stdout, stderr, err := cli.Run("gateway", "request", "retry", requestID) + if err != nil { + assert.Contains(t, stdout+stderr, "not eligible for retry", "retry failed for unexpected reason: %v", err) + return + } + assert.Contains(t, stdout, "retry requested") +} + +func TestRequestRetryWithConnectionIds(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + requestID := requests[0].ID + // --connection-ids is passed to API; request may not be eligible for retry, so accept success or "not eligible" + stdout, stderr, err := cli.Run("gateway", "request", "retry", requestID, "--connection-ids", connID) + if err != nil { + assert.Contains(t, stdout+stderr, "not eligible for retry", "retry with connection-ids failed for unexpected reason: %v", err) + return + } + assert.Contains(t, stdout, "retry requested") +} + +func TestRequestIgnoredEvents(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + requestID := requests[0].ID + + // May return empty list; we only check the command succeeds + cli.RunExpectSuccess("gateway", "request", "ignored-events", requestID) +} + +func TestRequestRawBody(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + stdout := cli.RunExpectSuccess("gateway", "request", "raw-body", requests[0].ID) + assert.Contains(t, stdout, "test") +} + +func TestRequestListWithId(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + cli.RunExpectSuccess("gateway", "request", "list", "--id", requests[0].ID) +} + +func TestRequestListWithStatus(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--status", "accepted", "--limit", "5") +} + +func TestRequestListWithVerified(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--verified", "true", "--limit", "5") +} + +func TestRequestListWithRejectionCause(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--rejection-cause", "VERIFICATION_FAILED", "--limit", "5") +} + +func TestRequestListWithCreatedAfter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--created-after", "2020-01-01T00:00:00Z", "--limit", "5") +} + +func TestRequestListWithCreatedBefore(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--created-before", "2030-01-01T00:00:00Z", "--limit", "5") +} + +func TestRequestListWithIngestedAtAfter(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--ingested-at-after", "2020-01-01T00:00:00Z", "--limit", "5") +} + +func TestRequestListWithIngestedAtBefore(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--ingested-at-before", "2030-01-01T00:00:00Z", "--limit", "5") +} + +func TestRequestListWithHeaders(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--headers", "{}", "--limit", "5") +} + +func TestRequestListWithBody(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--body", "{}", "--limit", "5") +} + +func TestRequestListWithPath(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--path", "/", "--limit", "5") +} + +func TestRequestListWithParsedQuery(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--parsed-query", "{}", "--limit", "5") +} + +func TestRequestListWithOrderBy(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--order-by", "created_at", "--limit", "5") +} + +func TestRequestListWithDir(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + cli.RunExpectSuccess("gateway", "request", "list", "--dir", "desc", "--limit", "5") +} + +func TestRequestListWithNext(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "request", "list", "--limit", "1", "--next", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestRequestListWithPrev(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + _, _, err := cli.Run("gateway", "request", "list", "--limit", "1", "--prev", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestRequestEventsWithNext(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + // --next is passed to API; invalid cursor may return 400, so just verify command runs and params are accepted + _, _, err := cli.Run("gateway", "request", "events", requests[0].ID, "--limit", "1", "--next", "dummy") + if err != nil { + // API may reject invalid cursor; ensure we're not crashing + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestRequestEventsWithPrev(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + _, _, err := cli.Run("gateway", "request", "events", requests[0].ID, "--limit", "1", "--prev", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestRequestIgnoredEventsWithNext(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + _, _, err := cli.Run("gateway", "request", "ignored-events", requests[0].ID, "--limit", "1", "--next", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} + +func TestRequestIgnoredEventsWithPrev(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + cli := NewCLIRunner(t) + connID, _ := createConnectionAndTriggerEvent(t, cli) + t.Cleanup(func() { deleteConnection(t, cli, connID) }) + var conn Connection + require.NoError(t, cli.RunJSON(&conn, "gateway", "connection", "get", connID)) + requests := pollForRequestsBySourceID(t, cli, conn.Source.ID) + _, _, err := cli.Run("gateway", "request", "ignored-events", requests[0].ID, "--limit", "1", "--prev", "dummy") + if err != nil { + assert.Contains(t, err.Error(), "exit status") + } +} diff --git a/test/acceptance/source_test.go b/test/acceptance/source_test.go new file mode 100644 index 0000000..94ae4cb --- /dev/null +++ b/test/acceptance/source_test.go @@ -0,0 +1,517 @@ +package acceptance + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSourceList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "source", "list") + assert.NotEmpty(t, stdout) +} + +func TestSourceCreateAndDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + stdout := cli.RunExpectSuccess("gateway", "source", "get", sourceID) + assert.Contains(t, stdout, sourceID) + assert.Contains(t, stdout, "WEBHOOK") +} + +func TestSourceGetByName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-get-" + timestamp + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", "--name", name, "--type", "WEBHOOK") + require.NoError(t, err) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "source", "get", name) + assert.Contains(t, stdout, src.ID) + assert.Contains(t, stdout, name) +} + +func TestSourceCreateWithDescription(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-desc-" + timestamp + desc := "Test source description" + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", "--name", name, "--type", "WEBHOOK", "--description", desc) + require.NoError(t, err) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "source", "get", src.ID) + assert.Contains(t, stdout, desc) +} + +func TestSourceUpdate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + newName := "test-src-updated-" + generateTimestamp() + cli.RunExpectSuccess("gateway", "source", "update", sourceID, "--name", newName) + + stdout := cli.RunExpectSuccess("gateway", "source", "get", sourceID) + assert.Contains(t, stdout, newName) +} + +func TestSourceUpsertCreate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-src-upsert-create-" + generateTimestamp() + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "upsert", name, "--type", "WEBHOOK") + require.NoError(t, err) + require.NotEmpty(t, src.ID) + assert.Equal(t, name, src.Name) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) +} + +func TestSourceUpsertUpdate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-src-upsert-upd-" + generateTimestamp() + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "upsert", name, "--type", "WEBHOOK") + require.NoError(t, err) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + newDesc := "Updated via upsert" + err = cli.RunJSON(&src, "gateway", "source", "upsert", name, "--description", newDesc) + require.NoError(t, err) + + stdout := cli.RunExpectSuccess("gateway", "source", "get", src.ID) + assert.Contains(t, stdout, newDesc) +} + +func TestSourceEnableDisable(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + cli.RunExpectSuccess("gateway", "source", "disable", sourceID) + stdout := cli.RunExpectSuccess("gateway", "source", "get", sourceID) + assert.Contains(t, stdout, "disabled") + + cli.RunExpectSuccess("gateway", "source", "enable", sourceID) + stdout = cli.RunExpectSuccess("gateway", "source", "get", sourceID) + assert.Contains(t, stdout, "active") +} + +func TestSourceCount(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "source", "count") + stdout = strings.TrimSpace(stdout) + assert.NotEmpty(t, stdout) + assert.Regexp(t, `^\d+$`, stdout) +} + +func TestSourceListFilterByName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + // Get name from get output or create with known name + var src Source + err := cli.RunJSON(&src, "gateway", "source", "get", sourceID) + require.NoError(t, err) + + stdout := cli.RunExpectSuccess("gateway", "source", "list", "--name", src.Name) + assert.Contains(t, stdout, src.ID) + assert.Contains(t, stdout, src.Name) +} + +func TestSourceListFilterByType(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "source", "list", "--type", "WEBHOOK", "--limit", "5") + // May be empty or have entries + assert.NotContains(t, stdout, "failed") +} + +func TestSourceDeleteForce(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + + cli.RunExpectSuccess("gateway", "source", "delete", sourceID, "--force") + + _, _, err := cli.Run("gateway", "source", "get", sourceID) + require.Error(t, err) +} + +func TestSourceUpsertDryRun(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-src-dryrun-" + generateTimestamp() + stdout := cli.RunExpectSuccess("gateway", "source", "upsert", name, "--type", "WEBHOOK", "--dry-run") + assert.Contains(t, stdout, "Dry Run") + assert.Contains(t, stdout, "CREATE") +} + +func TestSourceGetOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "get", sourceID, "--output", "json") + require.NoError(t, err) + assert.Equal(t, sourceID, src.ID) + assert.Equal(t, "WEBHOOK", src.Type) +} + +// TestSourceCreateWithWebhookSecret creates a source with --webhook-secret (individual flag). +// Uses STRIPE type because WEBHOOK does not allow auth config in the API. +func TestSourceCreateWithWebhookSecret(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-webhook-secret-" + timestamp + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", + "--name", name, + "--type", "STRIPE", + "--webhook-secret", "whsec_test_acceptance_123", + ) + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "source", "get", src.ID) + assert.Contains(t, stdout, name) + assert.Contains(t, stdout, "STRIPE") +} + +// TestSourceCreateWithAllowedHTTPMethods creates a source with --allowed-http-methods. +func TestSourceCreateWithAllowedHTTPMethods(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-methods-" + timestamp + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", + "--name", name, + "--type", "WEBHOOK", + "--allowed-http-methods", "POST,PUT,PATCH", + ) + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + cli.RunExpectSuccess("gateway", "source", "get", src.ID) +} + +// TestSourceCreateWithCustomResponse creates a source with custom response body and content type. +func TestSourceCreateWithCustomResponse(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-custom-resp-" + timestamp + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", + "--name", name, + "--type", "WEBHOOK", + "--custom-response-content-type", "json", + "--custom-response-body", `{"status":"received"}`, + ) + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + cli.RunExpectSuccess("gateway", "source", "get", src.ID) +} + +// TestSourceCreateWithConfigJSON creates a source with --config (JSON) for parity with individual flags. +// Uses STRIPE type; config uses config.auth (normalized from webhook_secret to auth.webhook_secret_key). +func TestSourceCreateWithConfigJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-config-json-" + timestamp + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", + "--name", name, + "--type", "STRIPE", + "--config", `{"webhook_secret":"whsec_from_json"}`, + ) + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + cli.RunExpectSuccess("gateway", "source", "get", src.ID) +} + +// TestSourceUpsertWithIndividualFlags creates via upsert with --webhook-secret (STRIPE), then updates with --description. +// Uses STRIPE type because WEBHOOK does not allow auth config in the API. +func TestSourceUpsertWithIndividualFlags(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-upsert-flags-" + timestamp + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "upsert", name, "--type", "STRIPE", "--webhook-secret", "whsec_upsert_123") + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + // Update via upsert with another flag (description; allowed_http_methods is WEBHOOK-only) + err = cli.RunJSON(&src, "gateway", "source", "upsert", name, "--description", "Updated via upsert flags") + require.NoError(t, err) + + cli.RunExpectSuccess("gateway", "source", "get", name) +} + +// TestSourceUpdateWithIndividualFlags updates a source by ID. OpenAPI spec PUT /sources/{id} allows +// name, type, description, config; the live API currently returns 422 when config is sent ("config is not allowed"), +// so we test update with --name only. CLI still sends config when flags are provided (spec-compliant). +func TestSourceUpdateWithIndividualFlags(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + newName := "test-src-updated-flags-" + generateTimestamp() + cli.RunExpectSuccess("gateway", "source", "update", sourceID, "--name", newName) + stdout := cli.RunExpectSuccess("gateway", "source", "get", sourceID) + assert.Contains(t, stdout, newName) +} + +// TestSourceCreateWithAuthThenGetWithInclude creates a source with --webhook-secret (STRIPE), then gets it +// with --include-auth. Correct structure is config.auth (not auth_type). GET with include=config.auth +// returns auth content; we assert the webhook secret is present in the get output. +func TestSourceCreateWithAuthThenGetWithInclude(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-auth-include-" + timestamp + webhookSecret := "whsec_acceptance_include_test" + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", + "--name", name, + "--type", "STRIPE", + "--webhook-secret", webhookSecret, + ) + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + // Get with --include-auth: auth content must be included (include=config.auth). + // Expected: config.auth contains the webhook secret. + stdout, _, err := cli.Run("gateway", "source", "get", src.ID, "--output", "json", "--include-auth") + require.NoError(t, err) + + // Log the raw response so we can see exactly what the API returned when the assertion fails + t.Logf("GET source with --include-auth response (excerpt): config key present=%v, full response length=%d", + strings.Contains(stdout, "\"config\""), len(stdout)) + if !strings.Contains(stdout, webhookSecret) { + t.Logf("Full API response body: %s", stdout) + } + + // Require that the webhook secret set at creation is present when include-auth is used + require.Contains(t, stdout, webhookSecret, + "get with --include-auth must return auth content; webhook secret set at creation should be present in output. "+ + "To reproduce: test/scripts/curl_get_source_include_auth.sh (set SOURCE_ID, HOOKDECK_API_KEY, HOOKDECK_PROJECT_ID)") + + // When include-auth is used, config may include auth_type (API returns it for HTTP; STRIPE may or may not) + var getResp map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &getResp), "parse get response as JSON") + config, ok := getResp["config"].(map[string]interface{}) + require.True(t, ok, "get with --include-auth must return config") + if _, hasAuth := config["auth"]; hasAuth { + if authType, hasType := config["auth_type"].(string); hasType && authType != "" { + t.Logf("Source config.auth_type: %s", authType) + } + // auth_type is required for HTTP sources; for STRIPE the API may omit it + } +} + +// TestSourceCreateWithAuthThenGetWithInclude_HTTP creates an HTTP source with --api-key, then gets it +// with --include-auth. Verifies auth round-trip and config.auth_type is API_KEY (required for HTTP source). +func TestSourceCreateWithAuthThenGetWithInclude_HTTP(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-src-http-apikey-include-" + timestamp + apiKey := "test_http_src_apikey_secret" + + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", + "--name", name, + "--type", "HTTP", + "--api-key", apiKey, + ) + require.NoError(t, err) + require.NotEmpty(t, src.ID) + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + stdout, _, err := cli.Run("gateway", "source", "get", src.ID, "--output", "json", "--include-auth") + require.NoError(t, err) + + require.Contains(t, stdout, apiKey, + "get with --include-auth must return auth content; API key set at creation should be present in output") + + var getResp map[string]interface{} + require.NoError(t, json.Unmarshal([]byte(stdout), &getResp), "parse get response as JSON") + config, ok := getResp["config"].(map[string]interface{}) + require.True(t, ok, "get with --include-auth must return config") + authType, hasType := config["auth_type"].(string) + require.True(t, hasType && authType != "", "HTTP source with auth must return config.auth_type") + assert.Equal(t, "API_KEY", authType, "HTTP source with API key should have config.auth_type API_KEY") + t.Logf("Source config.auth_type: %s", authType) +} + +// TestSourceUpdateWithNoFlagsFails asserts that running source update with no flags +// fails with "no updates specified" (CLI as user/agent would see). +func TestSourceUpdateWithNoFlagsFails(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + sourceID := createTestSource(t, cli) + t.Cleanup(func() { deleteSource(t, cli, sourceID) }) + + stdout, stderr, err := cli.Run("gateway", "source", "update", sourceID) + require.Error(t, err) + combined := stdout + stderr + assert.Contains(t, combined, "no updates specified", "error should tell user to set at least one flag") +} + +// TestStandaloneSourceThenConnection creates a standalone source via `source create`, +// then creates a connection that uses that source via --source-id. +func TestStandaloneSourceThenConnection(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + + // Create standalone source first + sourceName := "test-standalone-src-" + timestamp + var src Source + err := cli.RunJSON(&src, "gateway", "source", "create", "--name", sourceName, "--type", "WEBHOOK") + require.NoError(t, err, "Failed to create standalone source") + require.NotEmpty(t, src.ID, "Source ID should not be empty") + + t.Cleanup(func() { deleteSource(t, cli, src.ID) }) + + // Create connection using the standalone source + connName := "test-conn-standalone-src-" + timestamp + destName := "test-dst-standalone-" + timestamp + var conn Connection + err = cli.RunJSON(&conn, + "gateway", "connection", "create", + "--name", connName, + "--source-id", src.ID, + "--destination-name", destName, + "--destination-type", "CLI", + "--destination-cli-path", "/webhooks", + ) + require.NoError(t, err, "Failed to create connection with standalone source") + require.NotEmpty(t, conn.ID, "Connection ID should not be empty") + + t.Cleanup(func() { deleteConnection(t, cli, conn.ID) }) + + // Connection should use the same standalone source + assert.Equal(t, src.ID, conn.Source.ID, "Connection should use the standalone source ID") + assert.Equal(t, sourceName, conn.Source.Name, "Connection should use the standalone source name") + assert.Equal(t, "WEBHOOK", conn.Source.Type) + assert.Equal(t, destName, conn.Destination.Name) +} diff --git a/test/acceptance/transformation_test.go b/test/acceptance/transformation_test.go new file mode 100644 index 0000000..cd0b33f --- /dev/null +++ b/test/acceptance/transformation_test.go @@ -0,0 +1,382 @@ +package acceptance + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTransformationList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "transformation", "list") + assert.NotEmpty(t, stdout) +} + +func TestTransformationListWithName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + var trn Transformation + require.NoError(t, cli.RunJSON(&trn, "gateway", "transformation", "get", trnID)) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "list", "--name", trn.Name) + assert.Contains(t, stdout, trn.ID) + assert.Contains(t, stdout, trn.Name) +} + +func TestTransformationListWithOrderByDir(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "transformation", "list", "--order-by", "created_at", "--dir", "desc", "--limit", "5") + assert.NotEmpty(t, stdout) +} + +func TestTransformationCreateAndDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trnID) + assert.Contains(t, stdout, trnID) +} + +func TestTransformationGetByName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-trn-get-" + timestamp + code := `addHandler("transform", (request, context) => { return request; });` + + var trn Transformation + err := cli.RunJSON(&trn, "gateway", "transformation", "create", "--name", name, "--code", code) + require.NoError(t, err) + t.Cleanup(func() { deleteTransformation(t, cli, trn.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", name) + assert.Contains(t, stdout, trn.ID) + assert.Contains(t, stdout, name) +} + +func TestTransformationCreateWithEnv(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-trn-env-" + timestamp + code := `addHandler("transform", (request, context) => { return request; });` + + var trn Transformation + err := cli.RunJSON(&trn, "gateway", "transformation", "create", "--name", name, "--code", code, "--env", "FOO=bar,BAZ=qux") + require.NoError(t, err) + t.Cleanup(func() { deleteTransformation(t, cli, trn.ID) }) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trn.ID) + assert.Contains(t, stdout, trn.ID) +} + +func TestTransformationCreateWithCodeFile(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + dir := t.TempDir() + codePath := filepath.Join(dir, "code.js") + require.NoError(t, os.WriteFile(codePath, []byte(`addHandler("transform", (request, context) => { return request; });`), 0644)) + + cli := NewCLIRunner(t) + timestamp := generateTimestamp() + name := "test-trn-codefile-" + timestamp + + var trn Transformation + err := cli.RunJSON(&trn, "gateway", "transformation", "create", "--name", name, "--code-file", codePath) + require.NoError(t, err) + t.Cleanup(func() { deleteTransformation(t, cli, trn.ID) }) + + assert.Equal(t, name, trn.Name) + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trn.ID) + assert.Contains(t, stdout, trn.ID) +} + +func TestTransformationUpdate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + newName := "test-trn-updated-" + generateTimestamp() + cli.RunExpectSuccess("gateway", "transformation", "update", trnID, "--name", newName) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trnID) + assert.Contains(t, stdout, newName) +} + +func TestTransformationUpdateWithCode(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + newCode := `addHandler("transform", (request, context) => { request.headers["x-patched"] = "true"; return request; });` + cli.RunExpectSuccess("gateway", "transformation", "update", trnID, "--code", newCode) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trnID) + assert.Contains(t, stdout, "patched") +} + +func TestTransformationUpdateWithEnv(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + cli.RunExpectSuccess("gateway", "transformation", "update", trnID, "--env", "K=vvv") + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trnID) + assert.Contains(t, stdout, trnID) +} + +func TestTransformationDelete(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + + cli.RunExpectSuccess("gateway", "transformation", "delete", trnID, "--force") + + _, _, err := cli.Run("gateway", "transformation", "get", trnID) + require.Error(t, err) +} + +func TestTransformationUpsertCreate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-trn-upsert-create-" + generateTimestamp() + code := `addHandler("transform", (request, context) => { return request; });` + + var trn Transformation + err := cli.RunJSON(&trn, "gateway", "transformation", "upsert", name, "--code", code) + require.NoError(t, err) + require.NotEmpty(t, trn.ID) + assert.Equal(t, name, trn.Name) + t.Cleanup(func() { deleteTransformation(t, cli, trn.ID) }) +} + +func TestTransformationUpsertUpdate(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-trn-upsert-upd-" + generateTimestamp() + code := `addHandler("transform", (request, context) => { return request; });` + + var trn Transformation + err := cli.RunJSON(&trn, "gateway", "transformation", "upsert", name, "--code", code) + require.NoError(t, err) + t.Cleanup(func() { deleteTransformation(t, cli, trn.ID) }) + + newCode := `addHandler("transform", (request, context) => { request.headers["x-updated"] = "true"; return request; });` + err = cli.RunJSON(&trn, "gateway", "transformation", "upsert", name, "--code", newCode) + require.NoError(t, err) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "get", trn.ID) + assert.Contains(t, stdout, "updated") +} + +func TestTransformationUpsertDryRun(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + name := "test-trn-dryrun-" + generateTimestamp() + code := `addHandler("transform", (request, context) => { return request; });` + + stdout := cli.RunExpectSuccess("gateway", "transformation", "upsert", name, "--code", code, "--dry-run") + assert.Contains(t, stdout, "Dry Run") + assert.Contains(t, stdout, "CREATE") + + // No resource should exist + _, _, err := cli.Run("gateway", "transformation", "get", name) + require.Error(t, err) +} + +func TestTransformationCount(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "transformation", "count") + assert.NotEmpty(t, stdout) +} + +func TestTransformationCountWithName(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + var trn Transformation + require.NoError(t, cli.RunJSON(&trn, "gateway", "transformation", "get", trnID)) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "count", "--name", trn.Name) + assert.NotEmpty(t, stdout) +} + +func TestTransformationCountOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "transformation", "count", "--output", "json") + assert.True(t, len(stdout) > 0 && (stdout[0] == '{' || (stdout[0] >= '0' && stdout[0] <= '9'))) +} + +func TestTransformationRun(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + code := `addHandler("transform", (request, context) => { return request; });` + request := `{"headers":{}}` + + stdout, stderr, err := cli.Run("gateway", "transformation", "run", "--code", code, "--request", request) + require.NoError(t, err, "stdout: %s, stderr: %s", stdout, stderr) + assert.Contains(t, stdout, "Transformation run completed") +} + +func TestTransformationRunModifiesRequest(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + code := `addHandler("transform", (request, context) => { request.headers["x-transformed"] = "true"; return request; });` + request := `{"headers":{},"body":{"foo":"bar"}}` + + stdout, stderr, err := cli.Run("gateway", "transformation", "run", "--code", code, "--request", request) + require.NoError(t, err, "stdout: %s, stderr: %s", stdout, stderr) + assert.Contains(t, stdout, "Transformation run completed") + assert.Contains(t, stdout, "x-transformed", "transformation output should include the modified header") +} + +func TestTransformationRunWithTransformationID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + request := `{"headers":{}}` + stdout, stderr, err := cli.Run("gateway", "transformation", "run", "--id", trnID, "--request", request) + require.NoError(t, err, "stdout: %s, stderr: %s", stdout, stderr) + assert.Contains(t, stdout, "Transformation run completed") +} + +func TestTransformationRunWithEnv(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + code := `addHandler("transform", (request, context) => { return request; });` + request := `{"headers":{}}` + + stdout, stderr, err := cli.Run("gateway", "transformation", "run", "--code", code, "--request", request, "--env", "X=y") + require.NoError(t, err, "stdout: %s, stderr: %s", stdout, stderr) + assert.Contains(t, stdout, "Transformation run completed") +} + +func TestTransformationExecutionsList(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "executions", "list", trnID) + assert.NotEmpty(t, stdout) +} + +func TestTransformationExecutionsListWithLimit(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + stdout := cli.RunExpectSuccess("gateway", "transformation", "executions", "list", trnID, "--limit", "2", "--order-by", "created_at", "--dir", "desc") + assert.NotEmpty(t, stdout) +} + +func TestTransformationExecutionsGetNotFound(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + trnID := createTestTransformation(t, cli) + t.Cleanup(func() { deleteTransformation(t, cli, trnID) }) + + _, _, err := cli.Run("gateway", "transformation", "executions", "get", trnID, "exec_nonexistent") + require.Error(t, err) +} + +func TestTransformationListOutputJSON(t *testing.T) { + if testing.Short() { + t.Skip("Skipping acceptance test in short mode") + } + + cli := NewCLIRunner(t) + stdout := cli.RunExpectSuccess("gateway", "transformation", "list", "--output", "json") + assert.True(t, stdout == "[]" || (len(stdout) > 0 && stdout[0] == '[')) +} diff --git a/tools/generate-reference/main.go b/tools/generate-reference/main.go new file mode 100644 index 0000000..6f80efc --- /dev/null +++ b/tools/generate-reference/main.go @@ -0,0 +1,732 @@ +// generate-reference generates REFERENCE.md (or other files) from Cobra command metadata. +// +// It reads input file(s), finds GENERATE marker pairs, and replaces content between +// START and END with generated output. Structure is controlled by the input file. +// +// Marker format: +// - GENERATE_TOC:START ... GENERATE_END - table of contents +// - GENERATE_GLOBAL_FLAGS:START ... GENERATE_END - global options +// - GENERATE_HELP:path:START ... GENERATE_END - help output for command (e.g. path=connection) +// - GENERATE:path1|path2|path3:START ... GENERATE_END - command docs (| separator) +// +// Use to close any block (no need to repeat the command list). +// +// Usage: +// +// go run ./tools/generate-reference --input REFERENCE.md # in-place +// go run ./tools/generate-reference --input REFERENCE.template.md --output REFERENCE.md +// go run ./tools/generate-reference --input a.mdoc --input b.mdoc # batch, each in-place +// go run ./tools/generate-reference --input REFERENCE.md --check # verify up to date +// +// For website two-column layout (section/div/aside), add --no-toc, --no-examples-heading, and wrappers: +// +// --no-toc --no-examples-heading --wrapper-section-start "
" --wrapper-section-end "
" +// --wrapper-main-start "
" --wrapper-main-end "
" +// --wrapper-aside-start "" +// +// For REFERENCE.md (no wrappers, include in-page TOC): use --no-wrappers or omit wrapper flags. +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/hookdeck/hookdeck-cli/pkg/cmd" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type inputFiles []string + +func (i *inputFiles) String() string { return strings.Join(*i, ", ") } + +func (i *inputFiles) Set(v string) error { + *i = append(*i, v) + return nil +} + +// wrapConfig holds optional wrappers for layout (e.g. section/div/aside for two-column). +type wrapConfig struct { + sectionStart, sectionEnd string + mainStart, mainEnd string // wraps description, usage, flags + asideStart, asideEnd string // wraps examples +} + +// genConfig holds generation options (wrappers + toggles). +type genConfig struct { + wrap wrapConfig + noToc bool // omit in-page TOC (e.g. for website with sidebar nav) + noWrappers bool // ignore wrapper flags; use for REFERENCE.md + noExamplesHeading bool // omit "**Examples:**" heading before examples block +} + +func main() { + check := flag.Bool("check", false, "generate to temp and diff; exit 1 if different") + output := flag.String("output", "", "output file (optional; if omitted, write to input)") + noToc := flag.Bool("no-toc", false, "omit in-page table of contents (for website)") + noWrappers := flag.Bool("no-wrappers", false, "do not use section/div/aside wrappers (for REFERENCE.md)") + noExamplesHeading := flag.Bool("no-examples-heading", false, "omit Examples heading before examples block") + var inputs inputFiles + wrap := wrapConfig{} + flag.Var(&inputs, "input", "input file (required, repeatable for batch)") + flag.StringVar(&wrap.sectionStart, "wrapper-section-start", "", "wrap each command block start (e.g.
)") + flag.StringVar(&wrap.sectionEnd, "wrapper-section-end", "", "wrap each command block end (e.g.
)") + flag.StringVar(&wrap.mainStart, "wrapper-main-start", "", "wrap main content start (e.g.
)") + flag.StringVar(&wrap.mainEnd, "wrapper-main-end", "", "wrap main content end (e.g.
)") + flag.StringVar(&wrap.asideStart, "wrapper-aside-start", "", "wrap examples start (e.g. )") + flag.Parse() + + cfg := genConfig{wrap: wrap, noToc: *noToc, noWrappers: *noWrappers, noExamplesHeading: *noExamplesHeading} + if cfg.noWrappers { + cfg.wrap = wrapConfig{} + } + + if len(inputs) == 0 { + fmt.Fprintf(os.Stderr, "generate-reference: --input is required (use --input )\n") + os.Exit(1) + } + if len(inputs) > 1 && *output != "" { + fmt.Fprintf(os.Stderr, "generate-reference: cannot use --output with multiple --input (batch is in-place only)\n") + os.Exit(1) + } + + root := cmd.RootCmd() + + for _, inPath := range inputs { + outPath := inPath + if len(inputs) == 1 && *output != "" { + outPath = *output + } + + content, err := generateFromTemplate(root, inPath, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "generate: %v\n", err) + os.Exit(1) + } + + if *check { + existing, err := os.ReadFile(outPath) + if err != nil { + fmt.Fprintf(os.Stderr, "read %s: %v\n", outPath, err) + os.Exit(1) + } + if !bytes.Equal(existing, content) { + runCheck(outPath, content) + os.Exit(1) + } + fmt.Printf("%s is up to date\n", outPath) + continue + } + + if err := os.WriteFile(outPath, content, 0644); err != nil { + fmt.Fprintf(os.Stderr, "write %s: %v\n", outPath, err) + os.Exit(1) + } + fmt.Printf("Wrote %s\n", outPath) + } +} + +// generateMarker matches or etc. +// _[A-Z0-9_]+ matches _TOC, _GLOBAL_FLAGS; _HELP:[^:]+ matches _HELP:connection; :[^:]+ matches :path1|path2 +var generateMarkerRE = regexp.MustCompile(`(?m)^()\s*$`) + +// generateEndMarker is the simple closing tag (no command list required). +const generateEndMarker = "" +var generateEndRE = regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(generateEndMarker) + `\s*$`) + +// findNextEndMarker returns (start, length) of the next GENERATE*:END marker, or (-1, 0). +func findNextEndMarker(s string) (start, length int) { + idx := generateMarkerRE.FindStringIndex(s) + if idx == nil { + return -1, 0 + } + sub := generateMarkerRE.FindStringSubmatch(s) + if sub[3] != "END" { + return -1, 0 + } + return idx[0], idx[1] - idx[0] +} + +// pickFirstMatch returns (start, length) of whichever match appears first. (-1, 0) means no match. +func pickFirstMatch(simpleIdx []int, legacyStart, legacyLen int) (start, length int) { + legacyValid := legacyStart >= 0 + switch { + case simpleIdx != nil && !legacyValid: + return simpleIdx[0], simpleIdx[1] - simpleIdx[0] + case simpleIdx == nil && legacyValid: + return legacyStart, legacyLen + case simpleIdx != nil && legacyValid: + if simpleIdx[0] <= legacyStart { + return simpleIdx[0], simpleIdx[1] - simpleIdx[0] + } + return legacyStart, legacyLen + default: + return -1, 0 + } +} + +func generateFromTemplate(root *cobra.Command, templatePath string, cfg genConfig) ([]byte, error) { + input, err := os.ReadFile(templatePath) + if err != nil { + return nil, err + } + s := string(input) + + // Replace GENERATE_TIMESTAMP + ts := fmt.Sprintf("", time.Now().Format("2006-01-02")) + s = strings.Replace(s, "", ts, 1) + + // Find and replace each GENERATE block + pos := 0 + for { + idx := generateMarkerRE.FindStringIndex(s[pos:]) + if idx == nil { + break + } + startPos := pos + idx[0] + sub := generateMarkerRE.FindStringSubmatch(s[pos:]) + fullMatch := sub[1] + markerID := sub[2] // e.g. "GENERATE_TOC" or "GENERATE:login|logout|whoami" + startOrEnd := sub[3] // "START" or "END" + + if startOrEnd != "START" { + pos = startPos + len(fullMatch) + continue + } + + // Find the next END marker (simple GENERATE_END or legacy GENERATE_*:END) + afterStart := s[startPos+len(fullMatch):] + simpleIdx := generateEndRE.FindStringIndex(afterStart) + legacyStart, legacyLen := findNextEndMarker(afterStart) + endAt, endLen := pickFirstMatch(simpleIdx, legacyStart, legacyLen) + if endAt < 0 { + pos = startPos + len(fullMatch) + continue + } + + generated := generateBlockContent(root, markerID, cfg) + replacement := fullMatch + "\n" + generated + "\n" + generateEndMarker + s = s[:startPos] + replacement + afterStart[endAt+endLen:] + pos = startPos + len(replacement) + } + + return []byte(s), nil +} + +func generateBlockContent(root *cobra.Command, markerID string, cfg genConfig) string { + // markerID has trailing colon, e.g. "GENERATE_TOC:" or "GENERATE:paths:" + id := strings.TrimSuffix(markerID, ":") + wrap := cfg.wrap + switch id { + case "GENERATE_TOC": + return generateTOC(root) + case "GENERATE_GLOBAL_FLAGS": + return generateGlobalFlags(root) + default: + if strings.HasPrefix(id, "GENERATE_HELP:") { + path := strings.TrimPrefix(id, "GENERATE_HELP:") + return generateHelpOutput(root, path, wrap) + } + if strings.HasPrefix(id, "GENERATE:") { + paths := strings.Split(strings.TrimPrefix(id, "GENERATE:"), "|") + return generateCommands(root, paths, cfg) + } + return "" + } +} + +// generateHelpOutput returns structured help for a command group: description, usage, global flags, +// then examples. Order matches commandSection (flags before examples). Applies wrap config when set. +func generateHelpOutput(root *cobra.Command, path string, wrap wrapConfig) string { + path = strings.TrimSpace(path) + if path == "" { + return "" + } + parts := strings.Fields(path) + c, _, err := root.Find(parts) + if err != nil || c == root { + return "" + } + + // Main: section heading (## GroupName) then description, usage, global flags + var mainBuf bytes.Buffer + if wrap.sectionStart != "" { + if name := c.Name(); len(name) > 0 { + mainBuf.WriteString("## " + strings.ToUpper(name[:1]) + name[1:] + "\n\n") + } + } + desc := c.Long + if desc == "" { + desc = c.Short + } + desc, _ = extractExamplesFromLong(strings.TrimSpace(desc)) + if desc != "" { + mainBuf.WriteString(wrapFlagsInBackticks(desc) + "\n\n") + } + if c.Use != "" { + mainBuf.WriteString("**Usage:**\n\n```bash\n") + usage := c.UseLine() + if c.HasAvailableSubCommands() && !strings.Contains(usage, "[command]") { + usage += " [command]" + } + mainBuf.WriteString(usage + "\n") + mainBuf.WriteString("```\n\n") + } + mainBuf.WriteString(globalFlagsTable(root)) + mainStr := strings.TrimRight(mainBuf.String(), "\n") + + // Examples: available commands (comes after flags, no heading to avoid layout issues) + var examplesBuf bytes.Buffer + if c.HasAvailableSubCommands() { + examplesBuf.WriteString("```bash\n") + for _, sub := range c.Commands() { + if sub.Hidden { + continue + } + cmdPath := sub.CommandPath() + examplesBuf.WriteString(cmdPath) + if sub.Short != "" { + examplesBuf.WriteString(" # " + sub.Short) + } + examplesBuf.WriteString("\n") + } + examplesBuf.WriteString("```\n") + } + examplesStr := strings.TrimSpace(examplesBuf.String()) + + // Apply wrappers (match project/listen: section > div with blank line, aside) + var out bytes.Buffer + if wrap.sectionStart != "" { + out.WriteString(wrap.sectionStart + "\n") + } + if wrap.mainStart != "" { + out.WriteString(wrap.mainStart + "\n\n") + } + out.WriteString(mainStr) + if wrap.mainEnd != "" { + out.WriteString("\n" + wrap.mainEnd + "\n") + } + if wrap.asideStart != "" && examplesStr != "" { + out.WriteString(wrap.asideStart + "\n\n") + } + out.WriteString(examplesStr) + if wrap.asideEnd != "" && examplesStr != "" { + out.WriteString("\n" + wrap.asideEnd + "\n") + } + if wrap.sectionEnd != "" { + out.WriteString(wrap.sectionEnd + "\n") + } + return strings.TrimRight(out.String(), "\n") +} + +// globalFlagsTable returns root-level flags as a markdown table for command-group docs. +func globalFlagsTable(root *cobra.Command) string { + type flagInfo struct { + name, shorthand, ftype, usage string + } + var flags []flagInfo + seen := make(map[string]bool) + collect := func(f *pflag.Flag) { + if f.Hidden || f.Name == "help" || f.Name == "version" || seen[f.Name] { + return + } + seen[f.Name] = true + usage := f.Usage + if f.DefValue != "" && f.DefValue != "false" { + usage += fmt.Sprintf(" (default %q)", f.DefValue) + } + flags = append(flags, flagInfo{f.Name, f.Shorthand, f.Value.Type(), usage}) + } + root.PersistentFlags().VisitAll(collect) + root.Flags().VisitAll(collect) + if len(flags) == 0 { + return "" + } + var b bytes.Buffer + b.WriteString("**Global options:**\n\n") + b.WriteString("| Flag | Type | Description |\n") + b.WriteString("|------|------|-------------|\n") + for _, f := range flags { + var flag string + if f.shorthand != "" { + flag = fmt.Sprintf("`-%s, --%s`", f.shorthand, f.name) + } else { + flag = fmt.Sprintf("`--%s`", f.name) + } + usage := strings.ReplaceAll(f.usage, "|", "\\|") + b.WriteString(fmt.Sprintf("| %s | `%s` | %s |\n", flag, f.ftype, usage)) + } + return b.String() +} + +func generateTOC(root *cobra.Command) string { + // Groups only; no per-command sub-links + sections := []string{ + "Global Options", "Authentication", "Projects", "Local Development", "Gateway", + "Connections", "Sources", "Destinations", "Transformations", "Events", "Requests", + "Attempts", "Utilities", + } + var b bytes.Buffer + for _, title := range sections { + anchor := headingToAnchor(title) + b.WriteString(fmt.Sprintf("- [%s](#%s)\n", title, anchor)) + } + return strings.TrimRight(b.String(), "\n") +} + +func generateGlobalFlags(root *cobra.Command) string { + type flagInfo struct { + name, shorthand, ftype, usage string + } + var flags []flagInfo + seen := make(map[string]bool) + collect := func(f *pflag.Flag) { + if f.Hidden || seen[f.Name] { + return + } + seen[f.Name] = true + usage := f.Usage + if f.DefValue != "" && f.DefValue != "false" { + usage += fmt.Sprintf(" (default %q)", f.DefValue) + } + flags = append(flags, flagInfo{f.Name, f.Shorthand, f.Value.Type(), usage}) + } + root.PersistentFlags().VisitAll(collect) + root.Flags().VisitAll(collect) + + var b bytes.Buffer + b.WriteString("| Flag | Type | Description |\n") + b.WriteString("|------|------|-------------|\n") + for _, f := range flags { + var flag string + if f.shorthand != "" { + flag = fmt.Sprintf("`-%s, --%s`", f.shorthand, f.name) + } else { + flag = fmt.Sprintf("`--%s`", f.name) + } + usage := strings.ReplaceAll(f.usage, "|", "\\|") + b.WriteString(fmt.Sprintf("| %s | `%s` | %s |\n", flag, f.ftype, usage)) + } + return b.String() +} + +// rootFlagNames returns the set of non-hidden flag names defined on the root (persistent + local). +// Hidden flags (e.g. root's --api-key) are excluded so that commands that define their own +// visible version (e.g. ci's --api-key) will include it in their per-command flag table. +func rootFlagNames(root *cobra.Command) map[string]bool { + names := make(map[string]bool) + root.PersistentFlags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + names[f.Name] = true + } + }) + root.Flags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + names[f.Name] = true + } + }) + return names +} + +func generateCommands(root *cobra.Command, paths []string, cfg genConfig) string { + globalFlagNames := rootFlagNames(root) + wrap := cfg.wrap + + // Resolve commands and build in-page TOC + content + type item struct { + cmd *cobra.Command + path string + } + var items []item + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" { + continue + } + parts := strings.Fields(path) + c, _, err := root.Find(parts) + if err != nil || c == root { + continue + } + if c.HasSubCommands() && path != "gateway" { + continue + } + items = append(items, item{c, path}) + } + if len(items) == 0 { + return "" + } + + var b bytes.Buffer + // In-page TOC (optional; omit for website which has sidebar nav) + if !cfg.noToc && len(items) > 1 { + tocContent := "" + for _, it := range items { + label, anchor := commandHeadingLabelAndAnchor(root, it.cmd) + tocContent += fmt.Sprintf("- [%s](#%s)\n", label, anchor) + } + tocContent = strings.TrimSuffix(tocContent, "\n") + if wrap.sectionStart != "" && wrap.mainStart != "" { + b.WriteString(wrap.sectionStart + "\n") + b.WriteString(wrap.mainStart + "\n") + b.WriteString(tocContent + "\n") + b.WriteString(wrap.mainEnd + "\n") + b.WriteString(wrap.sectionEnd + "\n") + } else { + b.WriteString(tocContent + "\n\n") + } + } + + for _, it := range items { + section := commandSection(root, it.cmd, wrap, globalFlagNames, cfg.noExamplesHeading) + if section != "" { + b.WriteString(section) + b.WriteString("\n") + } + } + return strings.TrimRight(b.String(), "\n") +} + +// commandHeadingLabelAndAnchor returns the display label and anchor slug for a command. +// Root-level commands use title case (e.g. "CI", "Listen") and command name as anchor. +func commandHeadingLabelAndAnchor(root *cobra.Command, c *cobra.Command) (label, anchor string) { + if c.Parent() == root && len(c.Name()) > 0 { + title := c.Name() + if strings.EqualFold(title, "ci") { + title = "CI" + } else if len(title) > 1 { + title = strings.ToUpper(title[:1]) + title[1:] + } else { + title = strings.ToUpper(title) + } + return title, headingToAnchor(c.Name()) + } + return c.CommandPath(), headingToAnchor(c.CommandPath()) +} + +func commandSection(root *cobra.Command, c *cobra.Command, wrap wrapConfig, globalFlagNames map[string]bool, noExamplesHeading bool) string { + // Order: description, usage, flags, examples (usage and flags before examples) + + // Main content: heading (## for root-level commands, ### for subcommands), description, usage, flags + var mainBuf bytes.Buffer + label, _ := commandHeadingLabelAndAnchor(root, c) + level := "###" + if c.Parent() == root && len(c.Name()) > 0 { + level = "##" + } + mainBuf.WriteString(level + " " + label + "\n\n") + + desc := c.Short + if c.Long != "" { + desc = strings.TrimSpace(c.Long) + } + desc, examplesBlock := extractExamplesFromLong(desc) + if desc != "" { + mainBuf.WriteString(wrapFlagsInBackticks(desc) + "\n\n") + } + + if c.Use != "" { + mainBuf.WriteString("**Usage:**\n\n```bash\n") + mainBuf.WriteString(c.UseLine() + "\n") + mainBuf.WriteString("```\n\n") + } + + // Arguments: from Annotations["cli.arguments"] if present (JSON array of {name, type, description, required}) + if argsTable := renderArgumentsTable(c); argsTable != "" { + mainBuf.WriteString(argsTable) + } + + // Flags: command-specific only (root-level flags omitted from per-command tables) + var flagRows []struct { + flag, ftype, usage string + } + c.Flags().VisitAll(func(f *pflag.Flag) { + if f.Hidden || f.Name == "help" || f.Name == "version" || globalFlagNames[f.Name] { + return + } + var flag string + if f.Shorthand != "" { + flag = fmt.Sprintf("`-%s, --%s`", f.Shorthand, f.Name) + } else { + flag = fmt.Sprintf("`--%s`", f.Name) + } + usage := f.Usage + if f.DefValue != "" && f.DefValue != "false" { + usage += fmt.Sprintf(" (default %q)", f.DefValue) + } + flagRows = append(flagRows, struct{ flag, ftype, usage string }{flag, "`" + f.Value.Type() + "`", usage}) + }) + if len(flagRows) > 0 { + mainBuf.WriteString("**Flags:**\n\n") + mainBuf.WriteString("| Flag | Type | Description |\n") + mainBuf.WriteString("|------|------|-------------|\n") + for _, r := range flagRows { + usage := wrapFlagsInBackticks(r.usage) + usage = strings.ReplaceAll(usage, "|", "\\|") + mainBuf.WriteString(fmt.Sprintf("| %s | %s | %s |\n", r.flag, r.ftype, usage)) + } + mainBuf.WriteString("\n") + } + + // Examples (shown last, optionally wrapped in aside) + var examplesBuf bytes.Buffer + if examplesBlock != "" || c.Example != "" { + if !noExamplesHeading { + examplesBuf.WriteString("**Examples:**\n\n") + } + examplesBuf.WriteString("```bash\n") + if examplesBlock != "" { + examplesBuf.WriteString(examplesBlock) + } + if examplesBlock != "" && c.Example != "" { + examplesBuf.WriteString("\n\n") + } + if c.Example != "" { + examplesBuf.WriteString(normalizeIndent(strings.TrimSpace(c.Example))) + } + examplesBuf.WriteString("\n```\n\n") + } + + // Apply wrappers and assemble (match project/listen structure) + var out bytes.Buffer + mainStr := mainBuf.String() + examplesStr := examplesBuf.String() + + if wrap.sectionStart != "" { + out.WriteString(wrap.sectionStart + "\n") + } + if wrap.mainStart != "" { + out.WriteString(wrap.mainStart + "\n\n") + } + out.WriteString(mainStr) + if wrap.mainEnd != "" { + out.WriteString(wrap.mainEnd + "\n") + } + if wrap.asideStart != "" && examplesStr != "" { + out.WriteString(wrap.asideStart + "\n\n") + } + out.WriteString(examplesStr) + if wrap.asideEnd != "" && examplesStr != "" { + out.WriteString(wrap.asideEnd + "\n") + } + if wrap.sectionEnd != "" { + out.WriteString(wrap.sectionEnd + "\n") + } + return strings.TrimRight(out.String(), "\n") +} + +// argSpec describes a positional argument for the CLI docs. +type argSpec struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Required bool `json:"required"` +} + +// renderArgumentsTable returns a markdown Arguments table if c.Annotations["cli.arguments"] contains +// valid JSON array of argSpec. Used to document positional args before the Flags table. +func renderArgumentsTable(c *cobra.Command) string { + if c.Annotations == nil { + return "" + } + raw, ok := c.Annotations["cli.arguments"] + if !ok || raw == "" { + return "" + } + var args []argSpec + if err := json.Unmarshal([]byte(raw), &args); err != nil || len(args) == 0 { + return "" + } + var b bytes.Buffer + b.WriteString("**Arguments:**\n\n") + b.WriteString("| Argument | Type | Description |\n") + b.WriteString("|----------|------|-------------|\n") + for _, a := range args { + desc := a.Description + if a.Required { + desc = "**Required.** " + desc + } else { + desc = "**Optional.** " + desc + } + desc = wrapFlagsInBackticks(desc) + desc = strings.ReplaceAll(desc, "|", "\\|") + typ := "`" + a.Type + "`" + if a.Type == "" { + typ = "`string`" + } + argName := "`" + strings.ReplaceAll(a.Name, "`", "\\`") + "`" + b.WriteString(fmt.Sprintf("| %s | %s | %s |\n", argName, typ, desc)) + } + b.WriteString("\n") + return b.String() +} + +// extractExamplesFromLong finds an "Examples:" block in Long text and returns +// (prose, examplesBlock). The examples block is formatted in a code block by +// the caller so lines like "# comment" render as code, not markdown headings. +// Normalizes indentation by stripping the minimum common indent so all lines +// align consistently. +func extractExamplesFromLong(long string) (prose, examplesBlock string) { + idx := strings.Index(long, "Examples:") + if idx < 0 { + return long, "" + } + prose = strings.TrimSpace(long[:idx]) + afterLabel := long[idx+len("Examples:"):] + afterLabel = strings.TrimPrefix(afterLabel, "\n") + afterLabel = strings.TrimPrefix(afterLabel, "\r\n") + block := strings.ReplaceAll(afterLabel, "\t", " ") + examplesBlock = strings.TrimSpace(normalizeIndent(block)) + return prose, examplesBlock +} + +// wrapFlagsInBackticks wraps flag references (--flag-name) in backticks for markdown. +// Skips segments already inside backticks to avoid double-wrapping (RE2 has no lookbehind). +var flagLongRE = regexp.MustCompile(`--([a-zA-Z][a-zA-Z0-9_-]*)`) + +func wrapFlagsInBackticks(s string) string { + parts := strings.Split(s, "`") + for i := 0; i < len(parts); i += 2 { + parts[i] = flagLongRE.ReplaceAllString(parts[i], "`--$1`") + } + return strings.Join(parts, "`") +} + +// normalizeIndent strips leading whitespace from each line so all lines are +// consistently left-aligned in the output. +func normalizeIndent(block string) string { + lines := strings.Split(block, "\n") + var out []string + for _, line := range lines { + out = append(out, strings.TrimLeft(line, " \t")) + } + return strings.Join(out, "\n") +} + +func headingToAnchor(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "-") + return regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(s, "") +} + +func runCheck(refPath string, generated []byte) { + tmp, err := os.CreateTemp("", "reference-*.md") + if err != nil { + return + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + tmp.Write(generated) + tmp.Close() + absRef, _ := filepath.Abs(refPath) + fmt.Fprintf(os.Stderr, "%s is out of date. Run: go run ./tools/generate-reference --input