diff --git a/.agents/skills/custom-commands/SKILL.md b/.agents/skills/custom-commands/SKILL.md new file mode 100644 index 0000000..8bd7800 --- /dev/null +++ b/.agents/skills/custom-commands/SKILL.md @@ -0,0 +1,152 @@ +--- +name: agentmail-custom-commands +description: How to author custom commands for the agentmail CLI using the co-generated SDK. +--- + +# Custom Commands for `agentmail` + +## Overview + +The `agentmail` CLI supports user-authored custom commands that are +compiled into the binary alongside the auto-generated API commands. +Custom commands get a fully-wired SDK client that inherits the CLI's +auth, retries, TLS, base URL, and global headers — zero configuration required. + +## Architecture + +``` +cli/agentmail/custom.rs ← Your command handlers (protected by .fernignore) +cli/agentmail/sdk.rs ← Generated bridge: client() + block_on() +cli/agentmail/main.rs ← Generated entrypoint (calls custom::register) +agentmail-sdk/ ← Co-generated typed SDK crate +agentmail-types/ ← Co-generated typed model crate +``` + +## Adding a Custom Command + +### 1. Edit `cli/agentmail/custom.rs` + +This file is protected by `.fernignore` — `fern generate` will never +overwrite it. Register commands in the `register()` function: + +```rust +use agentmail_sdk::api::*; + +pub fn register(app: CliApp) -> CliApp { + let app = app.command( + clap::Command::new("get") + .about("Get Inbox") + .arg(clap::Arg::new("inbox_id").required(true)) + , + |matches, ctx| { + let inbox_id = matches.get_one::("inbox_id").unwrap(); + let client = super::sdk::client(ctx); + let result = super::sdk::block_on( + client.inboxes.get(inbox_id), + )?; + println!("{}", serde_json::to_string_pretty(&result).unwrap()); + Ok(()) + }, + ); + app +} +``` + +Then build and test: +```bash +cargo build +agentmail get +``` + +### 2. Available SDK Clients + +The `super::sdk::client(ctx)` call returns a `agentmail_sdk::api::Client` +with the following sub-clients: + +| Field | Type | Description | +|-------|------|-------------| +| `client.inboxes` | `agentmail_sdk::api::InboxesClient` | inboxes operations | +| `client.api_keys` | `agentmail_sdk::api::ApiKeysClient2` | api_keys operations | +| `client.drafts` | `agentmail_sdk::api::DraftsClient2` | drafts operations | +| `client.events` | `agentmail_sdk::api::EventsClient` | events operations | +| `client.lists` | `agentmail_sdk::api::ListsClient2` | lists operations | +| `client.messages` | `agentmail_sdk::api::MessagesClient` | messages operations | +| `client.metrics` | `agentmail_sdk::api::MetricsClient2` | metrics operations | +| `client.threads` | `agentmail_sdk::api::ThreadsClient2` | threads operations | +| `client.webhooks` | `agentmail_sdk::api::WebhooksClient2` | webhooks operations | +| `client.pods` | `agentmail_sdk::api::PodsClient` | pods operations | +| `client.api_keys` | `agentmail_sdk::api::ApiKeysClient3` | api_keys operations | +| `client.domains` | `agentmail_sdk::api::DomainsClient2` | domains operations | +| `client.drafts` | `agentmail_sdk::api::DraftsClient3` | drafts operations | +| `client.inboxes` | `agentmail_sdk::api::InboxesClient2` | inboxes operations | +| `client.lists` | `agentmail_sdk::api::ListsClient3` | lists operations | +| `client.metrics` | `agentmail_sdk::api::MetricsClient3` | metrics operations | +| `client.threads` | `agentmail_sdk::api::ThreadsClient3` | threads operations | +| `client.webhooks` | `agentmail_sdk::api::WebhooksClient3` | webhooks operations | +| `client.webhooks` | `agentmail_sdk::api::WebhooksClient` | webhooks operations | +| `client.agent` | `agentmail_sdk::api::AgentClient` | agent operations | +| `client.api_keys` | `agentmail_sdk::api::ApiKeysClient` | api_keys operations | +| `client.auth` | `agentmail_sdk::api::AuthClient` | auth operations | +| `client.domains` | `agentmail_sdk::api::DomainsClient` | domains operations | +| `client.drafts` | `agentmail_sdk::api::DraftsClient` | drafts operations | +| `client.lists` | `agentmail_sdk::api::ListsClient` | lists operations | +| `client.metrics` | `agentmail_sdk::api::MetricsClient` | metrics operations | +| `client.organizations` | `agentmail_sdk::api::OrganizationsClient` | organizations operations | +| `client.threads` | `agentmail_sdk::api::ThreadsClient` | threads operations | + +### 3. Key Patterns + +**Get the SDK client** (execution-sharing, fully authenticated): +```rust +let client = super::sdk::client(ctx); +``` + +**Run an async SDK call from a sync handler:** +```rust +let result = super::sdk::block_on( + client.some_resource.some_method(args), +)?; +``` + +**Use typed models for request/response serialization:** +```rust +use agentmail_sdk::api::*; +``` + +### 4. Authentication + +Custom commands automatically inherit the CLI's authentication. +The following auth schemes are configured: + +- **BearerAuth** (bearer): env `AGENTMAIL_API_KEY` +- **TokenAuth** (bearer): env `AGENTMAIL_TOKEN` + +No manual auth wiring is needed in custom command handlers. + +## Regeneration Safety + +| File | Regenerated? | Notes | +|------|-------------|-------| +| `cli/agentmail/custom.rs` | **No** | Protected by `.fernignore` | +| `cli/agentmail/sdk.rs` | Yes | Bridges AppContext → SDK client | +| `cli/agentmail/main.rs` | Yes | Calls `custom::register(app)` | +| `agentmail-sdk/` | Yes | Co-generated typed SDK crate | +| `agentmail-types/` | Yes | Co-generated typed models | + +After running `fern generate`, your `custom.rs` is preserved. All +generated code (SDK, types, glue, main.rs) is updated to match the +latest API spec. If the SDK surface changes (renamed methods, new +sub-clients), update your `custom.rs` to match. + +## Build & Test + +```bash +# Build the CLI (includes custom commands) +cargo build + +# Run your custom command +agentmail [args] + +# Run with verbose output for debugging +RUST_LOG=debug agentmail [args] +``` diff --git a/.claude b/.claude new file mode 120000 index 0000000..c0ca468 --- /dev/null +++ b/.claude @@ -0,0 +1 @@ +.agents \ No newline at end of file diff --git a/.fernignore b/.fernignore new file mode 100644 index 0000000..782b839 --- /dev/null +++ b/.fernignore @@ -0,0 +1,12 @@ +# Specify files that shouldn't be modified by Fern + +# Repo assets that predate the Fern generator — release history of the +# 0.7.x (Stainless-era) CLI and the security disclosure policy. The +# generator treats unknown files as stale output and deletes them, so +# they must be listed here to survive regeneration. +CHANGELOG.md +SECURITY.md + +# Hand-authored custom command bindings. The scaffold and its docs say +# this file is protected; without this entry a regeneration overwrites it. +cli/agentmail/custom.rs diff --git a/.github/actions/setup-go/action.yml b/.github/actions/setup-go/action.yml deleted file mode 100644 index eb7b832..0000000 --- a/.github/actions/setup-go/action.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Setup Go -description: 'Sets up Go environment with private modules' -inputs: - stainless-api-key: - required: false - description: the value of the STAINLESS_API_KEY secret -runs: - using: composite - steps: - - uses: stainless-api/retrieve-github-access-token@1f03f929b746c5b03dcdafa2bebbb18ca5672e1a # v1.0.0 - if: github.repository == 'stainless-sdks/agentmail-cli' - id: get_token - with: - repo: stainless-sdks/agentmail-go - stainless-api-key: ${{ inputs.stainless-api-key }} - - - name: Configure Git for access to the Go SDK's staging repo - if: github.repository == 'stainless-sdks/agentmail-cli' - shell: bash - run: git config --global url."https://x-access-token:${{ steps.get_token.outputs.github_access_token }}@github.com/stainless-sdks/agentmail-go".insteadOf "https://github.com/stainless-sdks/agentmail-go" - - - name: Setup go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version-file: ./go.mod - - - name: Bootstrap - shell: bash - run: ./scripts/bootstrap diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml deleted file mode 100644 index a7df3f7..0000000 --- a/.github/workflows/auto-merge-release.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Auto-merge release - -on: - repository_dispatch: - types: [go-sdk-released] - -jobs: - retrigger: - runs-on: ubuntu-latest - steps: - - name: Approve and auto-merge release PR - env: - GH_TOKEN: ${{ secrets.RELEASE_PAT }} - run: | - PR=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "release-please--branches--main--changes--next" --json number --jq '.[0].number') - [ -z "$PR" ] && exit 0 - gh pr review "$PR" --repo "$GITHUB_REPOSITORY" --approve - gh pr merge "$PR" --repo "$GITHUB_REPOSITORY" --squash --auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1556dd6..717328f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,116 +1,317 @@ -name: CI -on: - push: - branches: - - '**' - - '!integrated/**' - - '!stl-preview-head/**' - - '!stl-preview-base/**' - - '!generated' - - '!codegen/**' - - 'codegen/stl/**' - pull_request: - branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' +name: ci + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false env: - GOPRIVATE: github.com/agentmail-to/agentmail-go,github.com/stainless-sdks/agentmail-go + RUSTFLAGS: "-A warnings" jobs: - lint: - timeout-minutes: 10 - name: lint - runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + check: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check + run: cargo check + compile: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout repo + uses: actions/checkout@v6 - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Compile + run: cargo build + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 - - name: Link staging branch - if: github.repository == 'stainless-sdks/agentmail-cli' + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Test + run: cargo test + + # The npm packages take their version from the release tag, while the + # binary reports the version in Cargo.toml. Publishing when they disagree + # ships a package whose --version names a release that isn't on the + # registry, so refuse to publish instead (cargo-dist hard-fails on the + # same mismatch for the GitHub Release). + version: + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check tag matches crate version + shell: bash run: | - ./scripts/link 'github.com/stainless-sdks/agentmail-go@${{ github.ref_name }}' || true + set -euo pipefail - - name: Bootstrap - run: ./scripts/bootstrap + TAG_VERSION="${GITHUB_REF_NAME#v}" + CRATE_VERSION=$(cargo metadata --no-deps --format-version 1 \ + | jq -r --arg manifest "${PWD}/Cargo.toml" \ + '.packages[] | select(.manifest_path == $manifest) | .version') - - name: Run lints - run: ./scripts/lint + if [[ -z "${CRATE_VERSION}" ]]; then + echo "::error::Could not determine the crate version from cargo metadata (no package matched ${PWD}/Cargo.toml)." + exit 1 + fi - build: - timeout-minutes: 10 - name: build + if [[ "${TAG_VERSION}" != "${CRATE_VERSION}" ]]; then + echo "::error::Tag ${GITHUB_REF_NAME} publishes version ${TAG_VERSION}, but Cargo.toml is ${CRATE_VERSION}." + echo "::error::The binary would report ${CRATE_VERSION} from --version while the npm packages say ${TAG_VERSION}." + echo "::error::Set the generator's output version to ${TAG_VERSION} and regenerate, or tag v${CRATE_VERSION} instead." + exit 1 + fi + echo "Tag and crate version agree: ${TAG_VERSION}" + + publish: + needs: [check, compile, test, version] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ${{ matrix.runner }} permissions: contents: read id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + strategy: + # Don't cancel sibling matrix jobs on first failure — a transient + # failure on one platform would otherwise leave npm in a partial + # state (some platform packages published, others not, launcher + # never published), with no clean re-run since the already- + # published versions reject re-publish. + fail-fast: false + matrix: + include: + - rust-target: x86_64-unknown-linux-musl + runner: ubuntu-latest + npm-platform-suffix: linux-x64 + - rust-target: aarch64-unknown-linux-musl + runner: ubuntu-24.04-arm + npm-platform-suffix: linux-arm64 + - rust-target: x86_64-apple-darwin + runner: macos-latest + npm-platform-suffix: darwin-x64 + - rust-target: aarch64-apple-darwin + runner: macos-latest + npm-platform-suffix: darwin-arm64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout repo + uses: actions/checkout@v6 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.rust-target }} - - uses: ./.github/actions/setup-go + - name: Set up Node.js + uses: actions/setup-node@v6 with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} + node-version: "lts/Krypton" + registry-url: "https://registry.npmjs.org" + + - name: Install musl build tools + if: contains(matrix.rust-target, '-linux-musl') + run: | + sudo apt-get update + sudo apt-get install -y musl-tools - - name: Link staging branch - if: github.repository == 'stainless-sdks/agentmail-cli' + # The TLS backend and the keyring backend are selected per target by + # Cargo.toml, so no per-target feature flags are needed here. + # + # musl targets get a C compiler for the C dependencies, but not a + # linker: musl-gcc cannot produce static-pie, so it yields a binary + # that needs /lib/ld-musl-*.so.1 at runtime — which defeats the point + # of a musl build and crashes where that loader is absent. Left alone, + # rustc links the self-contained musl objects statically. + - name: Build release binary + shell: bash run: | - ./scripts/link 'github.com/stainless-sdks/agentmail-go@${{ github.ref_name }}' || true + if [[ "${{ matrix.rust-target }}" == *-linux-musl ]]; then + TARGET_UNDERSCORE=$(echo "${{ matrix.rust-target }}" | tr '-' '_') + export "CC_${TARGET_UNDERSCORE}=musl-gcc" + fi + cargo build --release --target ${{ matrix.rust-target }} - - name: Bootstrap - run: ./scripts/bootstrap + - name: Package and publish npm platform package + shell: bash + run: | + set -euo pipefail - - name: Run goreleaser - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 - with: - version: latest - args: release --snapshot --clean --skip=publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get GitHub OIDC Token - if: |- - github.repository == 'stainless-sdks/agentmail-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - id: github-oidc - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Upload tarball - if: |- - github.repository == 'stainless-sdks/agentmail-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - run: ./scripts/utils/upload-artifact.sh + VERSION="${GITHUB_REF_NAME#v}" + PLATFORM_PKG="agentmail-cli-${{ matrix.npm-platform-suffix }}" + PKG_DIR="npm-pkg/${PLATFORM_PKG}" + mkdir -p "${PKG_DIR}" - test: - timeout-minutes: 10 - name: test - runs-on: ${{ github.repository == 'stainless-sdks/agentmail-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + # Locate the compiled binary + BINARY_NAME="agentmail" + if [[ "${{ matrix.rust-target }}" == *"windows"* ]]; then + BINARY_NAME="agentmail.exe" + fi + cp "target/${{ matrix.rust-target }}/release/${BINARY_NAME}" "${PKG_DIR}/" + + # Write platform package.json + cat > "${PKG_DIR}/package.json" </dev/null || echo "0.0.0") + if npx -y semver@7.8.1 "${PKG_VERSION}" -r "<${CURRENT_LATEST}" > /dev/null 2>&1; then + echo "Publishing ${PKG_VERSION} with --tag backport (current latest is ${CURRENT_LATEST})" + npm publish --access public --tag backport + else + npm publish --access public + fi + fi + + publish-launcher: + needs: [publish] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Checkout repo + uses: actions/checkout@v6 - - uses: ./.github/actions/setup-go + - name: Set up Node.js + uses: actions/setup-node@v6 with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} + node-version: "lts/Krypton" + registry-url: "https://registry.npmjs.org" - - name: Link staging branch - if: github.repository == 'stainless-sdks/agentmail-cli' + - name: Publish launcher package + shell: bash run: | - ./scripts/link 'github.com/stainless-sdks/agentmail-go@${{ github.ref_name }}' || true + set -euo pipefail + + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="npm-pkg/launcher" + mkdir -p "${PKG_DIR}" + + BINARY_NAME="agentmail" + + # Build optionalDependencies map + OPTIONAL_DEPS="" + OPTIONAL_DEPS="${OPTIONAL_DEPS}\"agentmail-cli-linux-x64\": \"${VERSION}\"," + OPTIONAL_DEPS="${OPTIONAL_DEPS}\"agentmail-cli-linux-arm64\": \"${VERSION}\"," + OPTIONAL_DEPS="${OPTIONAL_DEPS}\"agentmail-cli-darwin-x64\": \"${VERSION}\"," + OPTIONAL_DEPS="${OPTIONAL_DEPS}\"agentmail-cli-darwin-arm64\": \"${VERSION}\"" + + cat > "${PKG_DIR}/package.json" < "${PKG_DIR}/bin/cli.js" <<'LAUNCHER' + #!/usr/bin/env node + "use strict"; + const { execFileSync } = require("child_process"); + const path = require("path"); + const os = require("os"); + + const PLATFORMS = { + "linux-x64": "agentmail-cli-linux-x64", + "linux-arm64": "agentmail-cli-linux-arm64", + "darwin-x64": "agentmail-cli-darwin-x64", + "darwin-arm64": "agentmail-cli-darwin-arm64", + }; + + const platformKey = os.platform() + "-" + os.arch(); + const pkg = PLATFORMS[platformKey]; + if (!pkg) { + console.error("Unsupported platform: " + platformKey); + process.exit(1); + } + + const binName = os.platform() === "win32" ? "agentmail.exe" : "agentmail"; + const binPath = path.join(require.resolve(pkg + "/package.json"), "..", binName); - - name: Bootstrap - run: ./scripts/bootstrap + try { + execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" }); + } catch (e) { + if (e && typeof e === "object" && "status" in e) { + process.exit(e.status); + } + throw e; + } + LAUNCHER - - name: Run tests - run: ./scripts/test + cd "${PKG_DIR}" + # Pre-release detection — require the semver "-" separator so a + # release tag like v1.0.0 for a package whose version string + # happens to contain "alpha"/"beta" as a substring isn't + # mis-tagged on npm. + if [[ "${VERSION}" == *-alpha* ]]; then + npm publish --access public --tag alpha + elif [[ "${VERSION}" == *-beta* ]]; then + npm publish --access public --tag beta + else + PKG_NAME=$(node -p "require('./package.json').name") + PKG_VERSION=$(node -p "require('./package.json').version") + CURRENT_LATEST=$(npm view "${PKG_NAME}" dist-tags.latest 2>/dev/null || echo "0.0.0") + if npx -y semver@7.8.1 "${PKG_VERSION}" -r "<${CURRENT_LATEST}" > /dev/null 2>&1; then + echo "Publishing ${PKG_VERSION} with --tag backport (current latest is ${CURRENT_LATEST})" + npm publish --access public --tag backport + else + npm publish --access public + fi + fi diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml deleted file mode 100644 index 0af93f4..0000000 --- a/.github/workflows/publish-release.yml +++ /dev/null @@ -1,59 +0,0 @@ ---- -name: Publish Release -permissions: - contents: write - -concurrency: - group: publish - -on: - push: - tags: - - "v*" - workflow_dispatch: {} -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 - with: - go-version-file: "go.mod" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@9ed2f89a662bf1735a48bc8557fd212fa902bebf # v6.1.0 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - - publish-npm: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 - registry-url: https://registry.npmjs.org - - name: Update npm package version - working-directory: npm - run: | - VERSION="${GITHUB_REF_NAME#v}" - npm pkg set binaryVersion="$VERSION" - npm version "$VERSION" --no-git-tag-version --allow-same-version - - name: Publish to npm - working-directory: npm - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml deleted file mode 100644 index e672552..0000000 --- a/.github/workflows/release-doctor.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: Release Doctor -on: - pull_request: - branches: - - main - workflow_dispatch: - -jobs: - release_doctor: - name: release doctor - runs-on: ubuntu-latest - if: github.repository == 'agentmail-to/agentmail-cli' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Check release environment - run: | - bash ./bin/check-release-environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9c0264b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,335 @@ +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist +# +# Copyright 2022-2024, axodotdev +# SPDX-License-Identifier: MIT or Apache-2.0 +# +# CI that: +# +# * checks for a Git Tag that looks like a release +# * builds artifacts with dist (archives, installers, hashes) +# * uploads those artifacts to temporary workflow zip +# * on success, uploads the artifacts to a GitHub Release +# +# Note that the GitHub Release will be created with a generated +# title/body based on your changelogs. + +name: Release +permissions: + "contents": "write" + +# This task will run whenever you push a git tag that looks like a version +# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. +# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where +# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION +# must be a Cargo-style SemVer Version (must have at least major.minor.patch). +# +# If PACKAGE_NAME is specified, then the announcement will be for that +# package (erroring out if it doesn't have the given version or isn't dist-able). +# +# If PACKAGE_NAME isn't specified, then the announcement will be for all +# (dist-able) packages in the workspace with that version (this mode is +# intended for workspaces with only one dist-able package, or with all dist-able +# packages versioned/released in lockstep). +# +# If you push multiple tags at once, separate instances of this workflow will +# spin up, creating an independent announcement for each one. However, GitHub +# will hard limit this to 3 tags per commit, as it will assume more tags is a +# mistake. +# +# If there's a prerelease-style suffix to the version, then the release(s) +# will be marked as a prerelease. +on: + pull_request: + push: + tags: + - '**[0-9]+.[0-9]+.[0-9]+*' + +jobs: + # Run 'dist plan' (or host) to determine what tasks we need to do + plan: + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.plan.outputs.manifest }} + tag: ${{ !github.event.pull_request && github.ref_name || '' }} + tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} + publishing: ${{ !github.event.pull_request }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install dist + # we specify bash to get pipefail; it guards against the `curl` command + # failing. otherwise `sh` won't catch that `curl` returned non-0 + shell: bash + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.31.0/cargo-dist-installer.sh | sh" + - name: Cache dist + uses: actions/upload-artifact@v4 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/dist + # sure would be cool if github gave us proper conditionals... + # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible + # functionality based on whether this is a pull_request, and whether it's from a fork. + # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* + # but also really annoying to build CI around when it needs secrets to work right.) + - id: plan + run: | + dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json + echo "dist ran successfully" + cat plan-dist-manifest.json + echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + name: artifacts-plan-dist-manifest + path: plan-dist-manifest.json + + # Build and packages all the platform-specific things + build-local-artifacts: + name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) + # Let the initial task tell us to not run (currently very blunt) + needs: + - plan + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + strategy: + fail-fast: false + # Target platforms/runners are computed by dist in create-release. + # Each member of the matrix has the following arguments: + # + # - runner: the github runner + # - dist-args: cli flags to pass to dist + # - install-dist: expression to run to install dist on the runner + # + # Typically there will be: + # - 1 "global" task that builds universal installers + # - N "local" tasks that build each platform's binaries and platform-specific installers + matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} + runs-on: ${{ matrix.runner }} + container: ${{ matrix.container && matrix.container.image || null }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json + permissions: + "attestations": "write" + "contents": "read" + "id-token": "write" + steps: + - name: enable windows longpaths + run: | + git config --global core.longpaths true + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install Rust non-interactively if not already installed + if: ${{ matrix.container }} + run: | + if ! command -v cargo > /dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + echo "$HOME/.cargo/bin" >> $GITHUB_PATH + fi + - name: Install dist + run: ${{ matrix.install_dist.run }} + # cargo-dist guards the installer download with pipefail in the `plan` + # job but not here, where the same `curl ... | sh` runs once per matrix + # leg. A dead curl pipes nothing into `sh`, which exits 0, so the step + # above reports success and `dist build` fails two steps later with an + # opaque exit 127. Observed on 2 of 3 real releases. + # + # Deliberately does not branch on `matrix.install_dist.shell`: steps 1 + # and 3 reuse the matrix's own command and shell exactly as upstream + # does, so this can only ever add a retry — it cannot mis-route a leg + # and skip the install entirely. + - id: dist-check + name: Check dist installed + shell: bash + run: | + if dist --version > /dev/null 2>&1; then + echo "ok=yes" >> "$GITHUB_OUTPUT" + else + echo "ok=no" >> "$GITHUB_OUTPUT" + echo "::warning::The dist installer did not put 'dist' on PATH — most likely a transient download failure. Retrying." + fi + - name: Install dist (retry) + if: ${{ steps.dist-check.outputs.ok == 'no' }} + run: ${{ matrix.install_dist.run }} + - name: Verify dist + shell: bash + run: | + dist --version || { + echo "::error::The cargo-dist installer failed twice on this runner, so 'dist' is not available. This is usually a transient network failure downloading https://github.com/axodotdev/cargo-dist/releases — re-run this job." + exit 1 + } + # Get the dist-manifest + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - name: Install dependencies + run: | + ${{ matrix.packages_install }} + - name: Build artifacts + run: | + # Actually do builds and make zips and whatnot + dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json + echo "dist ran successfully" + - name: Attest + uses: actions/attest-build-provenance@v3 + with: + subject-path: "target/distrib/*${{ join(matrix.targets, ', ') }}*" + - id: cargo-dist + name: Post-build + # We force bash here just because github makes it really hard to get values up + # to "real" actions without writing to env-vars, and writing to env-vars has + # inconsistent syntax between shell and powershell. + shell: bash + run: | + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-local-${{ join(matrix.targets, '_') }} + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Build and package all the platform-agnostic(ish) things + build-global-artifacts: + needs: + - plan + - build-local-artifacts + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Get all the local artifacts for the global tasks to use (for e.g. checksums) + - name: Fetch local artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: cargo-dist + shell: bash + run: | + dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json + echo "dist ran successfully" + + # Parse out what we just built and upload it to scratch storage + echo "paths<> "$GITHUB_OUTPUT" + jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + cp dist-manifest.json "$BUILD_MANIFEST_NAME" + - name: "Upload artifacts" + uses: actions/upload-artifact@v4 + with: + name: artifacts-build-global + path: | + ${{ steps.cargo-dist.outputs.paths }} + ${{ env.BUILD_MANIFEST_NAME }} + + # Determines if we should publish/announce + host: + needs: + - plan + - build-local-artifacts + - build-global-artifacts + # Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine) + if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + runs-on: "ubuntu-22.04" + outputs: + val: ${{ steps.host.outputs.manifest }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive + - name: Install cached dist + uses: actions/download-artifact@v7 + with: + name: cargo-dist-cache + path: ~/.cargo/bin/ + - run: chmod +x ~/.cargo/bin/dist + # Fetch artifacts from scratch-storage + - name: Fetch artifacts + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: target/distrib/ + merge-multiple: true + - id: host + shell: bash + run: | + dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json + echo "artifacts uploaded and released successfully" + cat dist-manifest.json + echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" + - name: "Upload dist-manifest.json" + uses: actions/upload-artifact@v4 + with: + # Overwrite the previous copy + name: artifacts-dist-manifest + path: dist-manifest.json + # Create a GitHub Release while uploading all files to it + - name: "Download GitHub Artifacts" + uses: actions/download-artifact@v7 + with: + pattern: artifacts-* + path: artifacts + merge-multiple: true + - name: Cleanup + run: | + # Remove the granular manifests + rm -f artifacts/*-dist-manifest.json + - name: Create GitHub Release + env: + PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}" + ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}" + ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}" + RELEASE_COMMIT: "${{ github.sha }}" + run: | + # Write and read notes from a file to avoid quoting breaking things + echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt + + gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + + announce: + needs: + - plan + - host + # use "always() && ..." to allow us to wait for all publish jobs while + # still allowing individual publish jobs to skip themselves (for prereleases). + # "host" however must run to completion, no skipping allowed! + if: ${{ always() && needs.host.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + submodules: recursive diff --git a/.gitignore b/.gitignore index 5354f42..1bb66c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -.prism.log -.stdy.log -dist/ -/agentmail -*.exe +/target +**/*.rs.bk +.DS_Store +*.swp diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 7d2350c..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,93 +0,0 @@ -project_name: agentmail -version: 2 - -before: - hooks: - - mkdir -p completions - - sh -c "go run ./cmd/agentmail/main.go @completion bash > completions/agentmail.bash" - - sh -c "go run ./cmd/agentmail/main.go @completion zsh > completions/agentmail.zsh" - - sh -c "go run ./cmd/agentmail/main.go @completion fish > completions/agentmail.fish" - - sh -c "go run ./cmd/agentmail/main.go @manpages -o man" - -builds: - - id: macos - goos: [darwin] - goarch: [amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/agentmail/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: linux - goos: [linux] - goarch: ['386', arm, amd64, arm64] - env: - - CGO_ENABLED=0 - binary: '{{ .ProjectName }}' - main: ./cmd/agentmail/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: windows - goos: [windows] - goarch: ['386', amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/agentmail/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - -archives: - - id: linux-archive - ids: [linux] - name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [tar.gz] - files: - - completions/* - - man/*/* - - id: macos-archive - ids: [macos] - name_template: '{{ .ProjectName }}_{{ .Version }}_macos_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - - id: windows-archive - ids: [windows] - name_template: '{{ .ProjectName }}_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - -snapshot: - version_template: '{{ .Tag }}-next' - -nfpms: - - license: Apache-2.0 - maintainer: contact@agentmail.cc - bindir: /usr - formats: - - apk - - deb - - rpm - - termux.deb - - archlinux - contents: - - src: man/man1/*.1.gz - dst: /usr/share/man/man1/ -notarize: - macos: - - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' - ids: [macos] - - sign: - certificate: "{{.Env.MACOS_SIGN_P12}}" - password: "{{.Env.MACOS_SIGN_PASSWORD}}" - - notarize: - issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" - key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" - key: "{{.Env.MACOS_NOTARY_KEY}}" diff --git a/.release-please-manifest.json b/.release-please-manifest.json deleted file mode 100644 index ec46183..0000000 --- a/.release-please-manifest.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - ".": "0.7.14" -} \ No newline at end of file diff --git a/.stats.yml b/.stats.yml deleted file mode 100644 index 5df686d..0000000 --- a/.stats.yml +++ /dev/null @@ -1,4 +0,0 @@ -configured_endpoints: 95 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/agentmail/agentmail-710dd939e94b2dbb7a2d7ffae0f5dbc6bab5893c201299f452fcf39e7dbd886c.yml -openapi_spec_hash: 4eb49916915aeac5a7897470322459d4 -config_hash: 8c7283a75ec714092fa5f03087e28a65 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3797534 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3260 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agentmail-cli" +version = "1.0.0" +dependencies = [ + "agentmail_sdk", + "anyhow", + "base64", + "bytes", + "chrono", + "clap", + "clap_complete", + "clap_mangen", + "dotenvy", + "form_urlencoded", + "futures-util", + "hmac", + "httpdate", + "jmespath", + "keyring", + "libc", + "num-bigint", + "ordered-float", + "percent-encoding", + "pin-project", + "rand 0.8.6", + "reqwest", + "reqwest-sse", + "secrecy", + "serde", + "serde_json", + "serde_json_path", + "serde_qs", + "serde_yaml", + "serial_test", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", + "unicode-normalization", + "webbrowser", + "wiremock", +] + +[[package]] +name = "agentmail_sdk" +version = "0.1.0" +dependencies = [ + "agentmail_types", + "bytes", + "chrono", + "futures", + "reqwest", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "agentmail_types" +version = "0.0.0" +dependencies = [ + "base64", + "chrono", + "num-bigint", + "ordered-float", + "serde", + "serde_json", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clap_mangen" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" +dependencies = [ + "clap", + "roff", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jmespath" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "017f8f53dd3b8ada762acb1f850da2a742d0ef3f921c60849a644380de1d683a" +dependencies = [ + "lazy_static", + "serde", + "serde_json", + "slug", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "openssl", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", + "rand 0.8.6", + "serde", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reqwest-sse" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e0aedd78f3626aad0ac3352f6128b73d3b617080da13fbaf8d168dad82de89" +dependencies = [ + "async-stream", + "reqwest", + "tokio", + "tokio-stream", + "tokio-util", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_path" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b992cea3194eea663ba99a042d61cea4bd1872da37021af56f6a37e0359b9d33" +dependencies = [ + "inventory", + "nom", + "regex", + "serde", + "serde_json", + "serde_json_path_core", + "serde_json_path_macros", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_json_path_core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dde67d8dfe7d4967b5a95e247d4148368ddd1e753e500adb34b3ffe40c6bc1bc" +dependencies = [ + "inventory", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "serde_json_path_macros" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "517acfa7f77ddaf5c43d5f119c44a683774e130b4247b7d3210f8924506cfac8" +dependencies = [ + "inventory", + "serde_json_path_core", + "serde_json_path_macros_internal", +] + +[[package]] +name = "serde_json_path_macros_internal" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafbefbe175fa9bf03ca83ef89beecff7d2a95aaacd5732325b90ac8c3bd7b90" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_qs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332" +dependencies = [ + "itoa", + "percent-encoding", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.8.6", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" +dependencies = [ + "core-foundation 0.10.1", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..9607486 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,153 @@ +[package] +name = "agentmail-cli" +version = "1.0.0" +edition = "2021" +description = "Command-line interface for the AgentMail API. Send, receive, reply, and manage threaded email conversations from your terminal." +license = "MIT" +repository = "https://github.com/agentmail-to/agentmail-cli" +homepage = "https://agentmail.to" +authors = ["AgentMail "] +keywords = ["email", "api", "cli", "agent", "agentmail"] +categories = ["command-line-utilities", "web-programming"] + +[lib] +name = "fern_cli_sdk" +path = "src/lib.rs" + +[[bin]] +name = "agentmail" +path = "cli/agentmail/main.rs" + +[features] +# TLS backend selection. +# +# The backend is chosen per target by the `[target.'cfg(...)'.dependencies]` +# tables below, so a plain `cargo build` produces a working binary for every +# target we distribute: musl targets get rustls (a static binary cannot link +# the system OpenSSL), everything else gets the platform's native stack. +# `default` is therefore empty — a non-empty default would re-enable +# native-tls on musl and break the build, including under build systems that +# offer no way to pass per-target cargo flags (cargo-dist). +# +# The two features remain as explicit overrides: +# +# native-tls (cargo build --features native-tls) +# The OS's native TLS stack (Secure Transport on macOS, SChannel on +# Windows, OpenSSL on Linux). Honors the OS keychain / cert store — +# what users typically expect for an interactive CLI. +# +# rustls (cargo build --features rustls) +# The pure-Rust rustls crate. Produces self-contained binaries that +# don't depend on system OpenSSL — preferred for distribution to varied +# Linux servers, scratch Docker images, and cross-compiled musl/ARM +# builds. Does NOT read the OS keychain; users must use +# `_CA_BUNDLE` for custom roots. +default = [] +native-tls = ["reqwest/native-tls", "tokio-tungstenite/native-tls"] +rustls = ["reqwest/rustls-tls-native-roots", "tokio-tungstenite/rustls-tls-native-roots"] + +[dependencies] +anyhow = "1" +base64 = "0.22" +bytes = "1" +clap = { version = "4", features = ["derive", "string", "env"] } +clap_complete = "4" +clap_mangen = "0.2" +hmac = "0.12" +dotenvy = "0.15" +futures-util = "0.3" +httpdate = "1" +libc = "0.2" +percent-encoding = "2.3.2" +# `gzip` is not used by this crate directly — it is declared so the shipped +# Cargo.lock contains the gzip dependency closure. The generated API SDK crate +# enables `reqwest/gzip`, and because Cargo unifies features across a workspace +# reqwest is built with it regardless; without it declared here the lock is +# missing `async-compression` & friends and the generated output fails +# `cargo build --locked` / `cargo audit`. See patchCargoToml.ts, which inserts +# the generated crates into the lock but cannot resolve their registry closure. +reqwest = { version = "0.12", features = ["json", "multipart", "stream", "gzip"], default-features = false } + +# --- Lock-closure-only dependencies --- +# +# Not used by this crate. They are declared `optional` and enabled by no +# feature, so they never enter the build graph — but Cargo.lock records the +# whole dependency graph regardless of features, so declaring them here pins +# them (and their transitive closure) in the shipped lock. +# +# Why that is needed: the generated model/SDK crates depend on these, and +# `patchCargoToml.ts` inserts those crates into the lock as workspace members +# without being able to resolve their registry closure. Any package they need +# that is absent from this lock makes the generated output fail +# `cargo build --locked`, `cargo metadata --locked` and `cargo audit` — which +# fails silently in the sense that the build still works, so dependency +# auditing is quietly disabled instead of erroring. +# +# Versions must match what the model generator emits (see +# generators/rust/model). If a generated crate gains a dependency, add it here. +chrono = { version = "0.4", features = ["serde"], optional = true } +num-bigint = { version = "0.4", features = ["serde"], optional = true } +ordered-float = { version = "4.5", features = ["serde"], optional = true } +# Added by the generated SDK crate for specs with server-sent events (see +# AbstractRustGeneratorContext, which declares these two behind an `sse` feature). +reqwest-sse = { version = "0.1", optional = true } +pin-project = { version = "1.1", optional = true } + +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_json_path = "0.7" +serde_yaml = "0.9.34" +secrecy = "0.10" +serde_qs = "1.1.1" +sha2 = "0.10" +thiserror = "2" +webbrowser = "1" +rand = "0.8" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake"] } +tokio-util = { version = "0.7", features = ["io"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-appender = "0.2" +unicode-normalization = "0.1.25" +form_urlencoded = "1" +jmespath = "0.3" + +# Per-target TLS backend (see [features] above). Cargo unions the features +# requested here with those in [dependencies], so each table only names the +# TLS feature; the rest of the reqwest/tungstenite feature set stays in one +# place. +[target.'cfg(target_env = "musl")'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["rustls-tls-native-roots"] } + +[target.'cfg(not(target_env = "musl"))'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } +tokio-tungstenite = { version = "0.24", default-features = false, features = ["native-tls"] } +# The OS keyring backend, absent on musl: `sync-secret-service` reaches D-Bus +# through libdbus, which cannot be linked into a static binary. A musl build +# is a static/container build, where secret-service is unreachable anyway, so +# those binaries use the file-backed credential store (see ADR-0008 and the +# matching `cfg(not(target_env = "musl"))` gates in src/auth/keyring_store.rs). +keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "vendored"] } + +[package.metadata.dist] +dist = true + +# The profile that 'cargo dist' will build with +[dependencies.agentmail_sdk] +path = "agentmail-sdk" + +[profile.dist] +inherits = "release" +lto = "thin" + +[build-dependencies] +serde = "1" +serde_yaml = "0.9.34" + +[dev-dependencies] +serial_test = "3.4.0" +tempfile = "3" +wiremock = "0.6" +tokio = { version = "1", features = ["full"] } diff --git a/LICENSE b/LICENSE index ada9bb6..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ @@ -186,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Agentmail + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 5d57b35..8bc284a 100644 --- a/README.md +++ b/README.md @@ -1,103 +1,145 @@ # AgentMail CLI -The official CLI for the [AgentMail API](https://docs.agentmail.to). +[![npm shield](https://img.shields.io/npm/v/agentmail-cli)](https://www.npmjs.com/package/agentmail-cli) + +Command-line interface for the AgentMail API. + +## Table of contents + +- [Installation](#installation) +- [Authentication](#authentication) +- [Quick start](#quick-start) +- [Usage](#usage) +- [Documentation](#documentation) +- [Advanced](#advanced) + - [Common flags](#common-flags) + - [Environment variables](#environment-variables) + - [Output formats](#output-formats) + - [Shell completion](#shell-completion) ## Installation -```sh -npm install -g agentmail-cli +### Shell (macOS / Linux) + +```bash +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/agentmail-to/agentmail-cli/releases/latest/download/agentmail-cli-installer.sh | sh ``` -## Setup +### PowerShell (Windows) -```sh -export AGENTMAIL_API_KEY=am_us_xxx +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://github.com/agentmail-to/agentmail-cli/releases/latest/download/agentmail-cli-installer.ps1 | iex" ``` -## Usage +### npm -```sh -agentmail [resource] [flags...] +```bash +npm install -g agentmail-cli ``` -```sh -# List inboxes -agentmail inboxes list +Or run directly without installing: -# Create an inbox -agentmail inboxes create --display-name "My Inbox" +```bash +npx agentmail-cli --help +``` -# Send a message -agentmail inboxes:messages send \ - --inbox-id inb_xxx \ - --to user@example.com \ - --subject "Hello" \ - --text "Hi there" +### Build from source -# List threads -agentmail inboxes:threads list --inbox-id inb_xxx -``` +If you prefer to build from source, install the [Rust toolchain](https://rustup.rs/) and run: -Use `--help` on any command for details. +```bash +cargo build --release +./target/release/agentmail --help +``` -## Environment variables +## Authentication -| Environment variable | Required | -| -------------------- | -------- | -| `AGENTMAIL_API_KEY` | yes | +Set the following environment variable(s) before using the CLI: -## Global flags +```bash +export AGENTMAIL_API_KEY="" +export AGENTMAIL_TOKEN="" +``` -- `--api-key` (can also be set with `AGENTMAIL_API_KEY` env var) -- `--help` - Show command line usage -- `--debug` - Enable debug logging (includes HTTP request/response details) -- `--version`, `-v` - Show the CLI version -- `--base-url` - Use a custom API backend URL -- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) -- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) +A `.env` file in the working directory is also supported — the CLI auto-loads it on startup. -### Passing files as arguments +## Quick start -To pass files to your API, you can use the `@myfile.ext` syntax: +List available commands: ```bash -agentmail --arg @abe.jpg +agentmail --help ``` -Files can also be passed inside JSON or YAML blobs: +Call an API endpoint: ```bash -agentmail --arg '{image: "@abe.jpg"}' -# Equivalent: -agentmail < ``` -If you need to pass a string literal that begins with an `@` sign, you can -escape the `@` sign to avoid accidentally passing a file. +Run `agentmail --help` to see available methods for a resource. + +## Usage + +Every API resource appears as a subcommand (e.g. `agentmail `). Run `agentmail --help` to see available methods. + +Provide request parameters as flags or as JSON: ```bash -agentmail --username '\@abe' +agentmail --json '{"key": "value"}' ``` -#### Explicit encoding +## Documentation + +See [reference.md](./reference.md) for the full command reference. + +## Advanced + +### Common flags + +These flags are available on every operation: + +| Flag | Description | +|------|-------------| +| `--dry-run` | Validate the request locally and print the HTTP request without sending it | +| `--json ` | Supply a request body as JSON (or `-` to read stdin) | +| `--params ` | Merge extra parameters as JSON (overrides individual flags) | +| `--format ` | Output format (default `json`) | +| `--output ` | Write binary responses to a file | +| `--base-url ` | Override the API base URL | +| `--page-all` | Auto-paginate and stream results as NDJSON | +| `--page-limit ` | Max pages to fetch when auto-paginating (default `10`) | +| `-q, --quiet` | Suppress stdout output on success (errors still go to stderr) | + +### Environment variables -For JSON endpoints, the CLI tool does filetype sniffing to determine whether the -file contents should be sent as a string literal (for plain text files) or as a -base64-encoded string literal (for binary files). If you need to explicitly send -the file as either plain text or base64-encoded data, you can use -`@file://myfile.txt` (for string encoding) or `@data://myfile.dat` (for -base64-encoding). Note that absolute paths will begin with `@file://` or -`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`). +| Variable | Description | +|----------|-------------| +| `AGENTMAIL_BASE_URL` | Override the API base URL | +| `AGENTMAIL_CA_BUNDLE` | Path to PEM file with extra trust roots (or `SSL_CERT_FILE`) | +| `AGENTMAIL_INSECURE=1` | Skip TLS verification (debugging only) | +| `AGENTMAIL_PROXY` | HTTP(S) proxy URL | +| `AGENTMAIL_TIMEOUT_SECS` | Total request timeout in seconds | + +Standard environment variables (`HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` / `SSL_CERT_FILE`) are also honored. + +### Output formats + +Use the global `--format` flag to control output. Supported values: `json` (default), `table`, `yaml`, `csv`. ```bash -agentmail --arg @data://file.txt +# Pipe JSON output through jq +agentmail --format json | jq + +# Machine-readable catalog of every operation +agentmail --help --format json | jq 'length' ``` -## Documentation +### Shell completion + +Generate shell completion scripts: + +```bash +agentmail completion +``` -[docs.agentmail.to](https://docs.agentmail.to) diff --git a/SKILL.md b/SKILL.md deleted file mode 100644 index c0788d5..0000000 --- a/SKILL.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -name: agentmail -description: Send and receive emails programmatically using the AgentMail API via CLI ---- - -# AgentMail CLI - -Use the `agentmail` CLI to send and receive emails programmatically. Requires `AGENTMAIL_API_KEY` environment variable. - -## Install - -```bash -npm install -g agentmail-cli -``` - -## Core Commands - -### Inboxes - -```bash -# Create an inbox -agentmail inboxes create --display-name "My Agent" --username myagent --domain example.com - -# List inboxes -agentmail inboxes list - -# Get an inbox -agentmail inboxes retrieve --inbox-id - -# Delete an inbox -agentmail inboxes delete --inbox-id -``` - -### Send Email - -```bash -# Send a message from an inbox -agentmail inboxes:messages send --inbox-id \ - --to "recipient@example.com" \ - --subject "Hello" \ - --text "Message body" - -# Send with HTML -agentmail inboxes:messages send --inbox-id \ - --to "recipient@example.com" \ - --subject "Hello" \ - --html "

Hello

" - -# Reply to a message -agentmail inboxes:messages reply --inbox-id --message-id \ - --text "Reply body" - -# Forward a message -agentmail inboxes:messages forward --inbox-id --message-id \ - --to "someone@example.com" -``` - -### Read Email - -```bash -# List messages in an inbox -agentmail inboxes:messages list --inbox-id - -# Get a specific message -agentmail inboxes:messages retrieve --inbox-id --message-id - -# List threads -agentmail inboxes:threads list --inbox-id - -# Get a thread -agentmail inboxes:threads retrieve --inbox-id --thread-id -``` - -### Drafts - -```bash -# Create a draft -agentmail inboxes:drafts create --inbox-id \ - --to "recipient@example.com" \ - --subject "Draft" \ - --text "Draft body" - -# Send a draft -agentmail inboxes:drafts send --inbox-id --draft-id -``` - -### Pods - -Pods group inboxes together. - -```bash -# Create a pod -agentmail pods create --name "My Pod" - -# Create an inbox in a pod -agentmail pods:inboxes create --pod-id --display-name "Pod Inbox" - -# List threads in a pod -agentmail pods:threads list --pod-id -``` - -### Webhooks - -```bash -# Create a webhook for new messages -agentmail webhooks create --url "https://example.com/webhook" --event-type message.received - -# List webhooks -agentmail webhooks list -``` - -### Domains - -```bash -# Add a custom domain -agentmail domains create --domain example.com --feedback-enabled false - -# Verify domain DNS -agentmail domains verify --domain-id - -# Get DNS records to configure -agentmail domains get-zone-file --domain-id -``` - -## Global Flags - -All commands support: `--api-key`, `--base-url`, `--environment`, `--format`, `--debug`. - -## Output Formats - -Use `--format` to control output: `json` (default), `pretty`, `yaml`, `jsonl`, `raw`, `explore`. diff --git a/agentmail-sdk/.github/workflows/ci.yml b/agentmail-sdk/.github/workflows/ci.yml new file mode 100644 index 0000000..e93e982 --- /dev/null +++ b/agentmail-sdk/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: ci + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + RUSTFLAGS: "-A warnings" + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Check + run: cargo check + + compile: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Compile + run: cargo build + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Rust + uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Test + run: cargo test + diff --git a/agentmail-sdk/.gitignore b/agentmail-sdk/.gitignore new file mode 100644 index 0000000..f75d277 --- /dev/null +++ b/agentmail-sdk/.gitignore @@ -0,0 +1,5 @@ +/target +**/*.rs.bk +Cargo.lock +.DS_Store +*.swp \ No newline at end of file diff --git a/agentmail-sdk/Cargo.toml b/agentmail-sdk/Cargo.toml new file mode 100644 index 0000000..cd81305 --- /dev/null +++ b/agentmail-sdk/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "agentmail_sdk" +version = "0.1.0" +edition = "2021" +description = "Rust SDK for agentmail_sdk generated by Fern" +license = "MIT" +repository = "https://github.com/fern-api/fern" +documentation = "https://docs.rs/agentmail_sdk" + +[lib] +doctest = false + +[dependencies] +bytes = "1.0" +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +reqwest = { version = "0.12", features = ["json", "stream", "gzip"], default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" +tokio = { version = "1.0", features = ["full"] } + +[dev-dependencies] +tokio-test = "0.4" + +[dependencies.agentmail_types] +path = "../agentmail-types" diff --git a/agentmail-sdk/rustfmt.toml b/agentmail-sdk/rustfmt.toml new file mode 100644 index 0000000..872221f --- /dev/null +++ b/agentmail-sdk/rustfmt.toml @@ -0,0 +1,4 @@ +# Generated by Fern +edition = "2021" +max_width = 100 +use_small_heuristics = "Default" \ No newline at end of file diff --git a/agentmail-sdk/src/api/mod.rs b/agentmail-sdk/src/api/mod.rs new file mode 100644 index 0000000..8247711 --- /dev/null +++ b/agentmail-sdk/src/api/mod.rs @@ -0,0 +1,17 @@ +//! API client and types for the AgentMail +//! +//! This module contains all the API definitions including request/response types +//! and client implementations for interacting with the API. +//! +//! ## Modules +//! +//! - [`resources`] - Service clients and endpoints + +pub mod resources; + +pub use resources::{ + AgentClient, ApiClient, ApiKeysClient, AuthClient, DomainsClient, DraftsClient, InboxesClient, + ListsClient, MetricsClient, OrganizationsClient, PodsClient, ThreadsClient, WebhooksClient, +}; + +pub use agentmail_types::*; diff --git a/agentmail-sdk/src/api/resources/agent/agent.rs b/agentmail-sdk/src/api/resources/agent/agent.rs new file mode 100644 index 0000000..06ba8d4 --- /dev/null +++ b/agentmail-sdk/src/api/resources/agent/agent.rs @@ -0,0 +1,136 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, RequestOptions}; +use reqwest::Method; + +pub struct AgentClient { + pub http_client: HttpClient, +} + +impl AgentClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Create a new agent organization with an inbox and API key. This endpoint is for signing up for the first time. If you've already signed up, you're all set — just use your existing API key. + /// + /// A 6-digit OTP is sent to the human's email for verification. + /// + /// This endpoint is idempotent. Calling it again with the same `human_email` will rotate the API key and resend the OTP if expired. + /// + /// The returned API key has limited permissions until the organization is verified via the verify endpoint. + /// + /// **CLI:** + /// ```bash + /// agentmail agent sign-up --human-email user@example.com --username my-agent + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .agent + /// .sign_up( + /// &AgentSignupRequest { + /// human_email: "human_email".to_string(), + /// username: "username".to_string(), + /// source: None, + /// referrer: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn sign_up( + &self, + request: &AgentSignupRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/agent/sign-up", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Verify an agent organization using the 6-digit OTP sent to the human's email during sign-up. + /// + /// On success, the organization is upgraded from `agent_unverified` to `agent_verified`, the send allowlist is removed, and free plan entitlements are applied. + /// + /// The OTP expires after 24 hours and allows a maximum of 10 attempts. If you run into any difficulties receiving the OTP code, you can also create an account on [console.agentmail.to](https://console.agentmail.to) using the human email address you provided to verify your account. + /// + /// **CLI:** + /// ```bash + /// agentmail agent verify --otp-code 123456 + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .agent + /// .verify( + /// &AgentVerifyRequest { + /// otp_code: "otp_code".to_string(), + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn verify( + &self, + request: &AgentVerifyRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/agent/verify", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/agent/mod.rs b/agentmail-sdk/src/api/resources/agent/mod.rs new file mode 100644 index 0000000..084a574 --- /dev/null +++ b/agentmail-sdk/src/api/resources/agent/mod.rs @@ -0,0 +1,2 @@ +pub mod agent; +pub use agent::AgentClient; diff --git a/agentmail-sdk/src/api/resources/api_keys/api_keys.rs b/agentmail-sdk/src/api/resources/api_keys/api_keys.rs new file mode 100644 index 0000000..1c2ff4a --- /dev/null +++ b/agentmail-sdk/src/api/resources/api_keys/api_keys.rs @@ -0,0 +1,438 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ApiKeysClient { + pub http_client: HttpClient, +} + +impl ApiKeysClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail api-keys list + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .list( + /// &APIKeysListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &ApiKeysListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/api-keys", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail api-keys create --name "My Key" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .create( + /// &CreateAPIKeyRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + request: &CreateApiKeyRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/api-keys", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail api-keys delete --api-key-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .delete(&APIKeyID("api_key_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + api_key_id: &ApiKeyId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/api-keys/{}", api_key_id.0), + None, + None, + options, + ) + .await + } + + /// List only public-key credentials visible to the bearer caller's scope. + /// Bearer credentials are never returned, even though both credential types + /// share storage and pagination indexes. Requires `api_key_read`. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .list_public_keys( + /// &ListPublicKeysQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list_public_keys( + &self, + request: &ListPublicKeysQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/api-keys/public-keys", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// Register a public P-256 JWK using an existing AgentMail bearer API key + /// with `api_key_create`. Re-registering the same JWK creates a new + /// credential ID; it does not replace or recover an earlier credential. + /// The private key must never be sent to AgentMail. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .create_public_key( + /// &CreatePublicKeyRequest { + /// public_key: PublicJwk { + /// kty: PublicJwkKty::Ec, + /// crv: PublicJwkCrv::P256, + /// x: PublicJwkCoordinate("x".to_string()), + /// y: PublicJwkCoordinate("y".to_string()), + /// }, + /// name: None, + /// scope: None, + /// expires_at: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create_public_key( + &self, + request: &CreatePublicKeyRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/api-keys/public-keys", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Permanently revoke one public-key credential. This hard-deletes the + /// credential; repeating the request returns not found. Requires + /// `api_key_delete`. + /// + /// # Arguments + /// + /// * `api_key_id` - Public-key credential ID returned by registration. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .revoke_public_key(&"api_key_id".to_string(), None) + /// .await; + /// } + /// ``` + pub async fn revoke_public_key( + &self, + api_key_id: &str, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/api-keys/public-keys/{}", api_key_id), + None, + None, + options, + ) + .await + } + + /// Rename the credential. All security-relevant fields are immutable. + /// Requires `api_key_update`. + /// + /// # Arguments + /// + /// * `api_key_id` - Public-key credential ID returned by registration. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .update_public_key_name( + /// &"api_key_id".to_string(), + /// &UpdatePublicKeyNameRequest { + /// name: "name".to_string(), + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update_public_key_name( + &self, + api_key_id: &str, + request: &UpdatePublicKeyNameRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/api-keys/public-keys/{}", api_key_id), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Invalidate every current public-key credential in the caller's + /// organization by advancing its AgentID key generation. The caller must be + /// organization-scoped and either have `api_key_delete` or, for a verified + /// self-serve agent organization, use an unrestricted unmanaged bearer + /// credential. No request body is accepted. + /// + /// `Idempotency-Key` is required and must be a UUID. Reusing the same UUID + /// returns the original permanent receipt without advancing the generation + /// again. A new UUID performs a new generation advance. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .api_keys + /// .revoke_all_agent_id_sign_in_keys(Some( + /// RequestOptions::new().additional_header("Idempotency-Key", "Idempotency-Key"), + /// )) + /// .await; + /// } + /// ``` + pub async fn revoke_all_agent_id_sign_in_keys( + &self, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/api-keys/public-keys/agentid-sign-in/revoke-all", + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/api_keys/mod.rs b/agentmail-sdk/src/api/resources/api_keys/mod.rs new file mode 100644 index 0000000..e4e8e8d --- /dev/null +++ b/agentmail-sdk/src/api/resources/api_keys/mod.rs @@ -0,0 +1,2 @@ +pub mod api_keys; +pub use api_keys::ApiKeysClient; diff --git a/agentmail-sdk/src/api/resources/auth/auth.rs b/agentmail-sdk/src/api/resources/auth/auth.rs new file mode 100644 index 0000000..de3d79b --- /dev/null +++ b/agentmail-sdk/src/api/resources/auth/auth.rs @@ -0,0 +1,51 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, RequestOptions}; +use reqwest::Method; + +pub struct AuthClient { + pub http_client: HttpClient, +} + +impl AuthClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Returns the identity and scope of the authenticated credential. Useful when a client holds a pod-scoped or inbox-scoped API key and needs to discover the parent organization, pod, or inbox without prior knowledge. + /// + /// **CLI:** + /// ```bash + /// agentmail auth me + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client.auth.me(None).await; + /// } + /// ``` + pub async fn me(&self, options: Option) -> Result { + self.http_client + .execute_request(Method::GET, "v0/auth/me", None, None, options) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/auth/mod.rs b/agentmail-sdk/src/api/resources/auth/mod.rs new file mode 100644 index 0000000..c5d7cba --- /dev/null +++ b/agentmail-sdk/src/api/resources/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub use auth::AuthClient; diff --git a/agentmail-sdk/src/api/resources/domains/domains.rs b/agentmail-sdk/src/api/resources/domains/domains.rs new file mode 100644 index 0000000..37532bc --- /dev/null +++ b/agentmail-sdk/src/api/resources/domains/domains.rs @@ -0,0 +1,366 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct DomainsClient { + pub http_client: HttpClient, +} + +impl DomainsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail domains list + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .list( + /// &DomainsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &DomainsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/domains", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail domains create --domain example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .create( + /// &CreateDomainRequest { + /// domain: DomainName("domain".to_string()), + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + request: &CreateDomainRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/domains", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail domains get --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .get(&DomainID("domain_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + domain_id: &DomainId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/domains/{}", domain_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail domains delete --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .delete(&DomainID("domain_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + domain_id: &DomainId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/domains/{}", domain_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail domains update --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .update( + /// &DomainID("domain_id".to_string()), + /// &UpdateDomainRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + domain_id: &DomainId, + request: &UpdateDomainRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/domains/{}", domain_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail domains get-zone-file --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .get_zone_file(&DomainID("domain_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get_zone_file( + &self, + domain_id: &DomainId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::GET, + &format!("v0/domains/{}/zone-file", domain_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail domains verify --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .domains + /// .verify(&DomainID("domain_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn verify( + &self, + domain_id: &DomainId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::POST, + &format!("v0/domains/{}/verify", domain_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/domains/mod.rs b/agentmail-sdk/src/api/resources/domains/mod.rs new file mode 100644 index 0000000..935f8b8 --- /dev/null +++ b/agentmail-sdk/src/api/resources/domains/mod.rs @@ -0,0 +1,2 @@ +pub mod domains; +pub use domains::DomainsClient; diff --git a/agentmail-sdk/src/api/resources/drafts/drafts.rs b/agentmail-sdk/src/api/resources/drafts/drafts.rs new file mode 100644 index 0000000..71a412f --- /dev/null +++ b/agentmail-sdk/src/api/resources/drafts/drafts.rs @@ -0,0 +1,178 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct DraftsClient { + pub http_client: HttpClient, +} + +impl DraftsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail drafts list + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .drafts + /// .list( + /// &DraftsListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &DraftsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/drafts", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail drafts get --draft-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .drafts + /// .get(&DraftID("draft_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + draft_id: &DraftId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/drafts/{}", draft_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail drafts get-attachment --draft-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .drafts + /// .get_attachment( + /// &DraftID("draft_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + draft_id: &DraftId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/drafts/{}/attachments/{}", draft_id.0, attachment_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/drafts/mod.rs b/agentmail-sdk/src/api/resources/drafts/mod.rs new file mode 100644 index 0000000..899c2be --- /dev/null +++ b/agentmail-sdk/src/api/resources/drafts/mod.rs @@ -0,0 +1,2 @@ +pub mod drafts; +pub use drafts::DraftsClient; diff --git a/agentmail-sdk/src/api/resources/inboxes/api_keys/inboxes_api_keys.rs b/agentmail-sdk/src/api/resources/inboxes/api_keys/inboxes_api_keys.rs new file mode 100644 index 0000000..7fb90ed --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/api_keys/inboxes_api_keys.rs @@ -0,0 +1,181 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ApiKeysClient2 { + pub http_client: HttpClient, +} + +impl ApiKeysClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes api-keys list --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .api_keys + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesAPIKeysListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesApiKeysListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/api-keys", inbox_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes api-keys create --inbox-id --name "My Key" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .api_keys + /// .create( + /// &InboxesInboxID("inbox_id".to_string()), + /// &CreateAPIKeyRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + inbox_id: &InboxesInboxId, + request: &CreateApiKeyRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/api-keys", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes api-keys delete --inbox-id --api-key-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .api_keys + /// .delete( + /// &InboxesInboxID("inbox_id".to_string()), + /// &APIKeyID("api_key_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + api_key_id: &ApiKeyId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/inboxes/{}/api-keys/{}", inbox_id.0, api_key_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/api_keys/mod.rs b/agentmail-sdk/src/api/resources/inboxes/api_keys/mod.rs new file mode 100644 index 0000000..65e1d30 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/api_keys/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_api_keys; +pub use inboxes_api_keys::ApiKeysClient2; diff --git a/agentmail-sdk/src/api/resources/inboxes/drafts/inboxes_drafts.rs b/agentmail-sdk/src/api/resources/inboxes/drafts/inboxes_drafts.rs new file mode 100644 index 0000000..413086c --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/drafts/inboxes_drafts.rs @@ -0,0 +1,425 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct DraftsClient2 { + pub http_client: HttpClient, +} + +impl DraftsClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts list --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesDraftsListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesDraftsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/drafts", inbox_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// Create a draft. Supply `in_reply_to` to create a reply draft (with + /// `reply_all` to address the whole thread), whose recipients, subject, and + /// threading are derived from the referenced message, or `forward_of` to + /// create a forward draft, which derives the subject, threading, and + /// forwarded content from the source but keeps recipients caller-supplied. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts create --inbox-id --to recipient@example.com --subject "Draft subject" --text "Draft body" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .create( + /// &InboxesInboxID("inbox_id".to_string()), + /// &CreateDraftRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + inbox_id: &InboxesInboxId, + request: &CreateDraftRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/drafts", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts get --inbox-id --draft-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .get( + /// &InboxesInboxID("inbox_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + inbox_id: &InboxesInboxId, + draft_id: &DraftId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/drafts/{}", inbox_id.0, draft_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts delete --inbox-id --draft-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .delete( + /// &InboxesInboxID("inbox_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + draft_id: &DraftId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/inboxes/{}/drafts/{}", inbox_id.0, draft_id.0), + None, + None, + options, + ) + .await + } + + /// Edit fields on an existing draft. Passing `null` clears a field (or `[]` + /// for a recipient field); `send_at: null` un-schedules a scheduled draft. + /// A draft that is already being sent cannot be edited. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts update --inbox-id --draft-id --subject "Updated subject" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .update( + /// &InboxesInboxID("inbox_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// &UpdateDraftRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + inbox_id: &InboxesInboxId, + draft_id: &DraftId, + request: &UpdateDraftRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/inboxes/{}/drafts/{}", inbox_id.0, draft_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts get-attachment --inbox-id --draft-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .get_attachment( + /// &InboxesInboxID("inbox_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + inbox_id: &InboxesInboxId, + draft_id: &DraftId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/inboxes/{}/drafts/{}/attachments/{}", + inbox_id.0, draft_id.0, attachment_id.0 + ), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes drafts send --inbox-id --draft-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .drafts + /// .send( + /// &InboxesInboxID("inbox_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// &UpdateMessageRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn send( + &self, + inbox_id: &InboxesInboxId, + draft_id: &DraftId, + request: &UpdateMessageRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/drafts/{}/send", inbox_id.0, draft_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/drafts/mod.rs b/agentmail-sdk/src/api/resources/inboxes/drafts/mod.rs new file mode 100644 index 0000000..60b3ea0 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/drafts/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_drafts; +pub use inboxes_drafts::DraftsClient2; diff --git a/agentmail-sdk/src/api/resources/inboxes/events/inboxes_events.rs b/agentmail-sdk/src/api/resources/inboxes/events/inboxes_events.rs new file mode 100644 index 0000000..c9d98d9 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/events/inboxes_events.rs @@ -0,0 +1,76 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct EventsClient { + pub http_client: HttpClient, +} + +impl EventsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// List label change events for an inbox. Returns events in reverse chronological order by default. Use for IMAP UID projection or audit logging. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes events list --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .events + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesEventsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesEventsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/events", inbox_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/events/mod.rs b/agentmail-sdk/src/api/resources/inboxes/events/mod.rs new file mode 100644 index 0000000..a7e7096 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/events/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_events; +pub use inboxes_events::EventsClient; diff --git a/agentmail-sdk/src/api/resources/inboxes/lists/inboxes_lists.rs b/agentmail-sdk/src/api/resources/inboxes/lists/inboxes_lists.rs new file mode 100644 index 0000000..7cdf583 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/lists/inboxes_lists.rs @@ -0,0 +1,259 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ListsClient2 { + pub http_client: HttpClient, +} + +impl ListsClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes lists list --inbox-id --direction --type + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .lists + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &InboxesListsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + direction: &Direction, + type_: &ListType, + request: &InboxesListsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/lists/{}/{}", inbox_id.0, direction, type_), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes lists create --inbox-id --direction --type --entry user@example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .lists + /// .create( + /// &InboxesInboxID("inbox_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &CreateListEntryRequest { + /// entry: "entry".to_string(), + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + inbox_id: &InboxesInboxId, + direction: &Direction, + type_: &ListType, + request: &CreateListEntryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/lists/{}/{}", inbox_id.0, direction, type_), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes lists get --inbox-id --direction --type --entry + /// ``` + /// + /// # Arguments + /// + /// * `entry` - Email address or domain. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .lists + /// .get( + /// &InboxesInboxID("inbox_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &"entry".to_string(), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + inbox_id: &InboxesInboxId, + direction: &Direction, + type_: &ListType, + entry: &str, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/inboxes/{}/lists/{}/{}/{}", + inbox_id.0, direction, type_, entry + ), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes lists delete --inbox-id --direction --type --entry + /// ``` + /// + /// # Arguments + /// + /// * `entry` - Email address or domain. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .lists + /// .delete( + /// &InboxesInboxID("inbox_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &"entry".to_string(), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + direction: &Direction, + type_: &ListType, + entry: &str, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!( + "v0/inboxes/{}/lists/{}/{}/{}", + inbox_id.0, direction, type_, entry + ), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/lists/mod.rs b/agentmail-sdk/src/api/resources/inboxes/lists/mod.rs new file mode 100644 index 0000000..5fa2ddf --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/lists/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_lists; +pub use inboxes_lists::ListsClient2; diff --git a/agentmail-sdk/src/api/resources/inboxes/messages/inboxes_messages.rs b/agentmail-sdk/src/api/resources/inboxes/messages/inboxes_messages.rs new file mode 100644 index 0000000..c74c38a --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/messages/inboxes_messages.rs @@ -0,0 +1,806 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct MessagesClient { + pub http_client: HttpClient, +} + +impl MessagesClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Lists messages in the inbox, most recent first. Pass `from`, `to`, or + /// `subject` to filter by substring. Filtered requests are served by + /// search, which caps `limit` at 100. For relevance-ranked full-text + /// search across sender, recipients, subject, and message body, use + /// `Search Messages`. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes messages list --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `from` - Filter to messages whose sender contains this value (substring match). Repeatable; all values must match. + /// * `to` - Filter to messages whose recipients (to, cc, or bcc) contain this value (substring match). Repeatable; all values must match. + /// * `subject` - Filter to messages whose subject contains this value (substring match). Repeatable; all values must match. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesMessagesListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// include_spam: None, + /// include_blocked: None, + /// include_unauthenticated: None, + /// include_trash: None, + /// from: None, + /// to: None, + /// subject: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesMessagesListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/messages", inbox_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .serialize("include_spam", request.include_spam.clone()) + .serialize("include_blocked", request.include_blocked.clone()) + .serialize( + "include_unauthenticated", + request.include_unauthenticated.clone(), + ) + .serialize("include_trash", request.include_trash.clone()) + .serialize("from", request.from.clone()) + .serialize("to", request.to.clone()) + .serialize("subject", request.subject.clone()) + .build(), + options, + ) + .await + } + + /// Full-text search across messages in the inbox, ranked by relevance. The + /// query is matched against the sender, recipients, and subject (substring) + /// and the message body (tokenized full text). Spam, trash, blocked, and + /// unauthenticated messages are always excluded. `limit` cannot exceed 100. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .search( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesMessagesSearchQueryRequest { + /// q: Query("q".to_string()), + /// limit: None, + /// page_token: None, + /// before: None, + /// after: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn search( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesMessagesSearchQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/messages/search", inbox_id.0), + None, + QueryBuilder::new() + .serialize("q", Some(request.q.clone())) + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages get --inbox-id --message-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .get( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/messages/{}", inbox_id.0, message_id.0), + None, + None, + options, + ) + .await + } + + /// Permanently deletes a message. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes messages delete --inbox-id --message-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .delete( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/inboxes/{}/messages/{}", inbox_id.0, message_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages update --inbox-id --message-id --add-labels read --remove-labels unread + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .update( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// &UpdateMessageRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + request: &UpdateMessageRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/inboxes/{}/messages/{}", inbox_id.0, message_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Fetch metadata for up to 500 messages in one request. Missing or + /// restricted IDs are silently omitted; compare `count` against `limit` + /// to detect misses. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes messages batch-get --inbox-id --message-ids --message-ids + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .batch_get( + /// &InboxesInboxID("inbox_id".to_string()), + /// &BatchGetMessagesRequest { + /// message_ids: BatchGetMessagesMessageIDs(vec![MessageID("message_ids".to_string())]), + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn batch_get( + &self, + inbox_id: &InboxesInboxId, + request: &BatchGetMessagesRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/messages/batch-get", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// Apply one label change to up to 50 messages in a single request. The + /// same add_labels and remove_labels apply to every message id, and at + /// least one of them must be provided. The update is atomic: either all + /// resolved messages are updated or none are. Missing or restricted ids + /// are silently excluded; compare `count` against `limit` to detect + /// exclusions. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes messages batch-update --inbox-id --message-ids --message-ids --add-labels read --remove-labels unread + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .batch_update( + /// &InboxesInboxID("inbox_id".to_string()), + /// &BatchUpdateMessagesRequest { + /// message_ids: BatchUpdateMessagesMessageIDs(vec![MessageID( + /// "message_ids".to_string(), + /// )]), + /// add_labels: None, + /// remove_labels: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn batch_update( + &self, + inbox_id: &InboxesInboxId, + request: &BatchUpdateMessagesRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/messages/batch-update", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages get-attachment --inbox-id --message-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .get_attachment( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/inboxes/{}/messages/{}/attachments/{}", + inbox_id.0, message_id.0, attachment_id.0 + ), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages get-raw --inbox-id --message-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .get_raw( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_raw( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/messages/{}/raw", inbox_id.0, message_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages send --inbox-id --to recipient@example.com --subject "Hello" --text "Body" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .send( + /// &InboxesInboxID("inbox_id".to_string()), + /// &SendMessageRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn send( + &self, + inbox_id: &InboxesInboxId, + request: &SendMessageRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/messages/send", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages reply --inbox-id --message-id --text "Reply text" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .reply( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// &ReplyToMessageRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn reply( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + request: &ReplyToMessageRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/messages/{}/reply", inbox_id.0, message_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages reply-all --inbox-id --message-id --text "Reply text" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .reply_all( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// &ReplyAllMessageRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn reply_all( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + request: &ReplyAllMessageRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!( + "v0/inboxes/{}/messages/{}/reply-all", + inbox_id.0, message_id.0 + ), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes messages forward --inbox-id --message-id --to recipient@example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .messages + /// .forward( + /// &InboxesInboxID("inbox_id".to_string()), + /// &MessageID("message_id".to_string()), + /// &SendMessageRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn forward( + &self, + inbox_id: &InboxesInboxId, + message_id: &MessageId, + request: &SendMessageRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!( + "v0/inboxes/{}/messages/{}/forward", + inbox_id.0, message_id.0 + ), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/messages/mod.rs b/agentmail-sdk/src/api/resources/inboxes/messages/mod.rs new file mode 100644 index 0000000..8c04869 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/messages/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_messages; +pub use inboxes_messages::MessagesClient; diff --git a/agentmail-sdk/src/api/resources/inboxes/metrics/inboxes_metrics.rs b/agentmail-sdk/src/api/resources/inboxes/metrics/inboxes_metrics.rs new file mode 100644 index 0000000..52ca59f --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/metrics/inboxes_metrics.rs @@ -0,0 +1,158 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct MetricsClient2 { + pub http_client: HttpClient, +} + +impl MetricsClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Counts of email events (sent, delivered, bounced, etc.) over time for + /// the inbox. Defaults to the last 24 hours; `start` must be within the + /// last 90 days, and a future `end` is clamped to now. Omit `period` for + /// individual event counts, or set it to sum counts into buckets of that + /// many seconds. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes metrics query-events --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .metrics + /// .query_events( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesMetricsQueryEventsQueryRequest { + /// event_types: vec![], + /// start: None, + /// end: None, + /// period: None, + /// limit: None, + /// descending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn query_events( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesMetricsQueryEventsQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/metrics/events", inbox_id.0), + None, + QueryBuilder::new() + .serialize_array("event_types", request.event_types.clone()) + .serialize("start", request.start.clone()) + .serialize("end", request.end.clone()) + .serialize("period", request.period.clone()) + .serialize("limit", request.limit.clone()) + .serialize("descending", request.descending.clone()) + .build(), + options, + ) + .await + } + + /// Cumulative usage series for the inbox. Each point is the running total + /// of the usage type at that timestamp, not the change within the bucket. + /// Inbox-scoped queries carry `storage_bytes`, `message_count`, and + /// `thread_count`; requested types that don't apply to the scope are + /// ignored. Defaults to the last 24 hours; `start` must be within the + /// last 90 days, and a future `end` is clamped to now. The range divided + /// by `period` must not exceed 1000 buckets. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .metrics + /// .query_usage( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesMetricsQueryUsageQueryRequest { + /// usage_types: vec![], + /// start: None, + /// end: None, + /// period: None, + /// limit: None, + /// descending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn query_usage( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesMetricsQueryUsageQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/metrics/usage", inbox_id.0), + None, + QueryBuilder::new() + .serialize_array("usage_types", request.usage_types.clone()) + .serialize("start", request.start.clone()) + .serialize("end", request.end.clone()) + .serialize("period", request.period.clone()) + .serialize("limit", request.limit.clone()) + .serialize("descending", request.descending.clone()) + .build(), + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/metrics/mod.rs b/agentmail-sdk/src/api/resources/inboxes/metrics/mod.rs new file mode 100644 index 0000000..50c36b0 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/metrics/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_metrics; +pub use inboxes_metrics::MetricsClient2; diff --git a/agentmail-sdk/src/api/resources/inboxes/mod.rs b/agentmail-sdk/src/api/resources/inboxes/mod.rs new file mode 100644 index 0000000..cab821a --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/mod.rs @@ -0,0 +1,303 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub mod api_keys; +pub use api_keys::ApiKeysClient2; +pub mod drafts; +pub use drafts::DraftsClient2; +pub mod events; +pub use events::EventsClient; +pub mod lists; +pub use lists::ListsClient2; +pub mod messages; +pub use messages::MessagesClient; +pub mod metrics; +pub use metrics::MetricsClient2; +pub mod threads; +pub use threads::ThreadsClient2; +pub mod webhooks; +pub use webhooks::WebhooksClient2; +pub struct InboxesClient { + pub http_client: HttpClient, + pub api_keys: ApiKeysClient2, + pub drafts: DraftsClient2, + pub events: EventsClient, + pub lists: ListsClient2, + pub messages: MessagesClient, + pub metrics: MetricsClient2, + pub threads: ThreadsClient2, + pub webhooks: WebhooksClient2, +} + +impl InboxesClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + api_keys: ApiKeysClient2::new(config.clone())?, + drafts: DraftsClient2::new(config.clone())?, + events: EventsClient::new(config.clone())?, + lists: ListsClient2::new(config.clone())?, + messages: MessagesClient::new(config.clone())?, + metrics: MetricsClient2::new(config.clone())?, + threads: ThreadsClient2::new(config.clone())?, + webhooks: WebhooksClient2::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes list + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .list( + /// &InboxesListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &InboxesListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/inboxes", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes create --display-name "My Agent" --username myagent --domain agentmail.to + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .create( + /// &InboxesCreateInboxRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + request: &InboxesCreateInboxRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/inboxes", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes get --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .get(&InboxesInboxID("inbox_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + inbox_id: &InboxesInboxId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}", inbox_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes delete --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .delete(&InboxesInboxID("inbox_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/inboxes/{}", inbox_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes update --inbox-id --display-name "Updated Name" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .update( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesUpdateInboxRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesUpdateInboxRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/inboxes/{}", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/threads/inboxes_threads.rs b/agentmail-sdk/src/api/resources/inboxes/threads/inboxes_threads.rs new file mode 100644 index 0000000..5e051a6 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/threads/inboxes_threads.rs @@ -0,0 +1,392 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ThreadsClient2 { + pub http_client: HttpClient, +} + +impl ThreadsClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Lists threads in the inbox, most recent first. Pass `senders`, + /// `recipients`, or `subject` to filter by substring. Filtered requests are + /// served by search, which caps `limit` at 100. For relevance-ranked + /// full-text search, use `Search Threads`. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes threads list --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `senders` - Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. + /// * `recipients` - Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. + /// * `subject` - Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .threads + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesThreadsListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// include_spam: None, + /// include_blocked: None, + /// include_unauthenticated: None, + /// include_trash: None, + /// senders: None, + /// recipients: None, + /// subject: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesThreadsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/threads", inbox_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .serialize("include_spam", request.include_spam.clone()) + .serialize("include_blocked", request.include_blocked.clone()) + .serialize( + "include_unauthenticated", + request.include_unauthenticated.clone(), + ) + .serialize("include_trash", request.include_trash.clone()) + .serialize("senders", request.senders.clone()) + .serialize("recipients", request.recipients.clone()) + .serialize("subject", request.subject.clone()) + .build(), + options, + ) + .await + } + + /// Full-text search across threads in the inbox, ranked by relevance. The + /// query is matched against senders, recipients, and subject (substring) + /// and the message body (tokenized full text). Spam, trash, blocked, and + /// unauthenticated threads are always excluded. `limit` cannot exceed 100. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .threads + /// .search( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesThreadsSearchQueryRequest { + /// q: Query("q".to_string()), + /// limit: None, + /// page_token: None, + /// before: None, + /// after: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn search( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesThreadsSearchQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/threads/search", inbox_id.0), + None, + QueryBuilder::new() + .serialize("q", Some(request.q.clone())) + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes threads get --inbox-id --thread-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .threads + /// .get( + /// &InboxesInboxID("inbox_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + inbox_id: &InboxesInboxId, + thread_id: &ThreadId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/threads/{}", inbox_id.0, thread_id.0), + None, + None, + options, + ) + .await + } + + /// Permanently deletes a thread and all of its messages. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes threads delete --inbox-id --thread-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .threads + /// .delete( + /// &InboxesInboxID("inbox_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + thread_id: &ThreadId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/inboxes/{}/threads/{}", inbox_id.0, thread_id.0), + None, + None, + options, + ) + .await + } + + /// Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .threads + /// .update( + /// &InboxesInboxID("inbox_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// &UpdateThreadRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + inbox_id: &InboxesInboxId, + thread_id: &ThreadId, + request: &UpdateThreadRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/inboxes/{}/threads/{}", inbox_id.0, thread_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes threads get-attachment --inbox-id --thread-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .threads + /// .get_attachment( + /// &InboxesInboxID("inbox_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + inbox_id: &InboxesInboxId, + thread_id: &ThreadId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/inboxes/{}/threads/{}/attachments/{}", + inbox_id.0, thread_id.0, attachment_id.0 + ), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/threads/mod.rs b/agentmail-sdk/src/api/resources/inboxes/threads/mod.rs new file mode 100644 index 0000000..eed96d4 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/threads/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_threads; +pub use inboxes_threads::ThreadsClient2; diff --git a/agentmail-sdk/src/api/resources/inboxes/webhooks/inboxes_webhooks.rs b/agentmail-sdk/src/api/resources/inboxes/webhooks/inboxes_webhooks.rs new file mode 100644 index 0000000..3555d10 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/webhooks/inboxes_webhooks.rs @@ -0,0 +1,410 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct WebhooksClient2 { + pub http_client: HttpClient, +} + +impl WebhooksClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes webhooks list --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .list( + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesWebhooksListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + inbox_id: &InboxesInboxId, + request: &InboxesWebhooksListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/webhooks", inbox_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// Create a webhook scoped to this inbox. + /// + /// **CLI:** + /// ```bash + /// agentmail inboxes webhooks create --inbox-id --url https://example.com/webhook --event-types message.received + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .create( + /// &InboxesInboxID("inbox_id".to_string()), + /// &WebhooksCreateInboxWebhookRequest { + /// url: WebhooksURL("url".to_string()), + /// event_types: WebhooksCreateWebhookEventTypes(EventTypes(vec![ + /// EventType::MessageReceived, + /// ])), + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + inbox_id: &InboxesInboxId, + request: &WebhooksCreateInboxWebhookRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/inboxes/{}/webhooks", inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes webhooks get --inbox-id --webhook-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .get( + /// &InboxesInboxID("inbox_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + inbox_id: &InboxesInboxId, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/inboxes/{}/webhooks/{}", inbox_id.0, webhook_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes webhooks delete --inbox-id --webhook-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .delete( + /// &InboxesInboxID("inbox_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + inbox_id: &InboxesInboxId, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/inboxes/{}/webhooks/{}", inbox_id.0, webhook_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail inboxes webhooks update --inbox-id --webhook-id --event-types message.received + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .update( + /// &InboxesInboxID("inbox_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// &WebhooksUpdateInboxWebhookRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + inbox_id: &InboxesInboxId, + webhook_id: &WebhooksWebhookId, + request: &WebhooksUpdateInboxWebhookRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/inboxes/{}/webhooks/{}", inbox_id.0, webhook_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// List the names of custom HTTP headers included with deliveries to this inbox-scoped webhook. + /// Header values are write-only and are never returned. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .get_headers( + /// &InboxesInboxID("inbox_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_headers( + &self, + inbox_id: &InboxesInboxId, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/inboxes/{}/webhooks/{}/headers", + inbox_id.0, webhook_id.0 + ), + None, + None, + options, + ) + .await + } + + /// Atomically set, replace, or remove custom HTTP headers included with deliveries to this + /// inbox-scoped webhook. Header values remain write-only. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .inboxes + /// .webhooks + /// .update_headers( + /// &InboxesInboxID("inbox_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// &WebhooksUpdateWebhookHeadersRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update_headers( + &self, + inbox_id: &InboxesInboxId, + webhook_id: &WebhooksWebhookId, + request: &WebhooksUpdateWebhookHeadersRequest, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::PATCH, + &format!( + "v0/inboxes/{}/webhooks/{}/headers", + inbox_id.0, webhook_id.0 + ), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/inboxes/webhooks/mod.rs b/agentmail-sdk/src/api/resources/inboxes/webhooks/mod.rs new file mode 100644 index 0000000..73af085 --- /dev/null +++ b/agentmail-sdk/src/api/resources/inboxes/webhooks/mod.rs @@ -0,0 +1,2 @@ +pub mod inboxes_webhooks; +pub use inboxes_webhooks::WebhooksClient2; diff --git a/agentmail-sdk/src/api/resources/lists/lists.rs b/agentmail-sdk/src/api/resources/lists/lists.rs new file mode 100644 index 0000000..682c44f --- /dev/null +++ b/agentmail-sdk/src/api/resources/lists/lists.rs @@ -0,0 +1,241 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ListsClient { + pub http_client: HttpClient, +} + +impl ListsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail lists list --direction --type + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .lists + /// .list( + /// &Direction::Send, + /// &ListType::Allow, + /// &ListsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + direction: &Direction, + type_: &ListType, + request: &ListsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/lists/{}/{}", direction, type_), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail lists create --direction --type --entry user@example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .lists + /// .create( + /// &Direction::Send, + /// &ListType::Allow, + /// &CreateListEntryRequest { + /// entry: "entry".to_string(), + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + direction: &Direction, + type_: &ListType, + request: &CreateListEntryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/lists/{}/{}", direction, type_), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail lists get --direction --type --entry + /// ``` + /// + /// # Arguments + /// + /// * `entry` - Email address or domain. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .lists + /// .get( + /// &Direction::Send, + /// &ListType::Allow, + /// &"entry".to_string(), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + direction: &Direction, + type_: &ListType, + entry: &str, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/lists/{}/{}/{}", direction, type_, entry), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail lists delete --direction --type --entry + /// ``` + /// + /// # Arguments + /// + /// * `entry` - Email address or domain. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .lists + /// .delete( + /// &Direction::Send, + /// &ListType::Allow, + /// &"entry".to_string(), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + direction: &Direction, + type_: &ListType, + entry: &str, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/lists/{}/{}/{}", direction, type_, entry), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/lists/mod.rs b/agentmail-sdk/src/api/resources/lists/mod.rs new file mode 100644 index 0000000..4f1a2cd --- /dev/null +++ b/agentmail-sdk/src/api/resources/lists/mod.rs @@ -0,0 +1,2 @@ +pub mod lists; +pub use lists::ListsClient; diff --git a/agentmail-sdk/src/api/resources/metrics/metrics.rs b/agentmail-sdk/src/api/resources/metrics/metrics.rs new file mode 100644 index 0000000..0adc0b2 --- /dev/null +++ b/agentmail-sdk/src/api/resources/metrics/metrics.rs @@ -0,0 +1,150 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct MetricsClient { + pub http_client: HttpClient, +} + +impl MetricsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Counts of email events (sent, delivered, bounced, etc.) over time for + /// the organization. Defaults to the last 24 hours; `start` must be within + /// the last 90 days, and a future `end` is clamped to now. Omit `period` + /// for individual event counts, or set it to sum counts into buckets of + /// that many seconds. + /// + /// **CLI:** + /// ```bash + /// agentmail metrics query-events + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .metrics + /// .query_events( + /// &MetricsQueryEventsQueryRequest { + /// event_types: vec![], + /// start: None, + /// end: None, + /// period: None, + /// limit: None, + /// descending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn query_events( + &self, + request: &MetricsQueryEventsQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/metrics/events", + None, + QueryBuilder::new() + .serialize_array("event_types", request.event_types.clone()) + .serialize("start", request.start.clone()) + .serialize("end", request.end.clone()) + .serialize("period", request.period.clone()) + .serialize("limit", request.limit.clone()) + .serialize("descending", request.descending.clone()) + .build(), + options, + ) + .await + } + + /// Cumulative usage series for the organization. Each point is the running + /// total of the usage type at that timestamp, not the change within the + /// bucket. Defaults to the last 24 hours; `start` must be within the last + /// 90 days, and a future `end` is clamped to now. The range divided by + /// `period` must not exceed 1000 buckets. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .metrics + /// .query_usage( + /// &MetricsQueryUsageQueryRequest { + /// usage_types: vec![], + /// start: None, + /// end: None, + /// period: None, + /// limit: None, + /// descending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn query_usage( + &self, + request: &MetricsQueryUsageQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/metrics/usage", + None, + QueryBuilder::new() + .serialize_array("usage_types", request.usage_types.clone()) + .serialize("start", request.start.clone()) + .serialize("end", request.end.clone()) + .serialize("period", request.period.clone()) + .serialize("limit", request.limit.clone()) + .serialize("descending", request.descending.clone()) + .build(), + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/metrics/mod.rs b/agentmail-sdk/src/api/resources/metrics/mod.rs new file mode 100644 index 0000000..396aad9 --- /dev/null +++ b/agentmail-sdk/src/api/resources/metrics/mod.rs @@ -0,0 +1,2 @@ +pub mod metrics; +pub use metrics::MetricsClient; diff --git a/agentmail-sdk/src/api/resources/mod.rs b/agentmail-sdk/src/api/resources/mod.rs new file mode 100644 index 0000000..da099ec --- /dev/null +++ b/agentmail-sdk/src/api/resources/mod.rs @@ -0,0 +1,79 @@ +//! Service clients and API endpoints +//! +//! This module contains client implementations for: +//! +//! - **Inboxes** +//! - **Pods** +//! - **Webhooks** +//! - **Agent** +//! - **ApiKeys** +//! - **Auth** +//! - **Domains** +//! - **Drafts** +//! - **Lists** +//! - **Metrics** +//! - **Organizations** +//! - **Threads** + +use crate::{ApiError, ClientConfig}; + +pub mod agent; +pub mod api_keys; +pub mod auth; +pub mod domains; +pub mod drafts; +pub mod inboxes; +pub mod lists; +pub mod metrics; +pub mod organizations; +pub mod pods; +pub mod threads; +pub mod webhooks; +pub struct ApiClient { + pub config: ClientConfig, + pub inboxes: InboxesClient, + pub pods: PodsClient, + pub webhooks: WebhooksClient, + pub agent: AgentClient, + pub api_keys: ApiKeysClient, + pub auth: AuthClient, + pub domains: DomainsClient, + pub drafts: DraftsClient, + pub lists: ListsClient, + pub metrics: MetricsClient, + pub organizations: OrganizationsClient, + pub threads: ThreadsClient, +} + +impl ApiClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + config: config.clone(), + inboxes: InboxesClient::new(config.clone())?, + pods: PodsClient::new(config.clone())?, + webhooks: WebhooksClient::new(config.clone())?, + agent: AgentClient::new(config.clone())?, + api_keys: ApiKeysClient::new(config.clone())?, + auth: AuthClient::new(config.clone())?, + domains: DomainsClient::new(config.clone())?, + drafts: DraftsClient::new(config.clone())?, + lists: ListsClient::new(config.clone())?, + metrics: MetricsClient::new(config.clone())?, + organizations: OrganizationsClient::new(config.clone())?, + threads: ThreadsClient::new(config.clone())?, + }) + } +} + +pub use agent::AgentClient; +pub use api_keys::ApiKeysClient; +pub use auth::AuthClient; +pub use domains::DomainsClient; +pub use drafts::DraftsClient; +pub use inboxes::InboxesClient; +pub use lists::ListsClient; +pub use metrics::MetricsClient; +pub use organizations::OrganizationsClient; +pub use pods::PodsClient; +pub use threads::ThreadsClient; +pub use webhooks::WebhooksClient; diff --git a/agentmail-sdk/src/api/resources/organizations/mod.rs b/agentmail-sdk/src/api/resources/organizations/mod.rs new file mode 100644 index 0000000..2165969 --- /dev/null +++ b/agentmail-sdk/src/api/resources/organizations/mod.rs @@ -0,0 +1,2 @@ +pub mod organizations; +pub use organizations::OrganizationsClient; diff --git a/agentmail-sdk/src/api/resources/organizations/organizations.rs b/agentmail-sdk/src/api/resources/organizations/organizations.rs new file mode 100644 index 0000000..df096e4 --- /dev/null +++ b/agentmail-sdk/src/api/resources/organizations/organizations.rs @@ -0,0 +1,51 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, RequestOptions}; +use reqwest::Method; + +pub struct OrganizationsClient { + pub http_client: HttpClient, +} + +impl OrganizationsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Returns the organization for the authenticated API key (usage limits, counts, and billing metadata). + /// + /// **CLI:** + /// ```bash + /// agentmail organizations get + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client.organizations.get(None).await; + /// } + /// ``` + pub async fn get(&self, options: Option) -> Result { + self.http_client + .execute_request(Method::GET, "v0/organizations", None, None, options) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/api_keys/mod.rs b/agentmail-sdk/src/api/resources/pods/api_keys/mod.rs new file mode 100644 index 0000000..8cafffb --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/api_keys/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_api_keys; +pub use pods_api_keys::ApiKeysClient3; diff --git a/agentmail-sdk/src/api/resources/pods/api_keys/pods_api_keys.rs b/agentmail-sdk/src/api/resources/pods/api_keys/pods_api_keys.rs new file mode 100644 index 0000000..8813ffe --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/api_keys/pods_api_keys.rs @@ -0,0 +1,181 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ApiKeysClient3 { + pub http_client: HttpClient, +} + +impl ApiKeysClient3 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods api-keys list --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .api_keys + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &PodsAPIKeysListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + request: &PodsApiKeysListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/api-keys", pod_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods api-keys create --pod-id --name "My Key" + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .api_keys + /// .create( + /// &PodsPodID("pod_id".to_string()), + /// &CreateAPIKeyRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + pod_id: &PodsPodId, + request: &CreateApiKeyRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/pods/{}/api-keys", pod_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods api-keys delete --pod-id --api-key-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .api_keys + /// .delete( + /// &PodsPodID("pod_id".to_string()), + /// &APIKeyID("api_key_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + api_key_id: &ApiKeyId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/pods/{}/api-keys/{}", pod_id.0, api_key_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/domains/mod.rs b/agentmail-sdk/src/api/resources/pods/domains/mod.rs new file mode 100644 index 0000000..6a0d19b --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/domains/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_domains; +pub use pods_domains::DomainsClient2; diff --git a/agentmail-sdk/src/api/resources/pods/domains/pods_domains.rs b/agentmail-sdk/src/api/resources/pods/domains/pods_domains.rs new file mode 100644 index 0000000..9bd1089 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/domains/pods_domains.rs @@ -0,0 +1,399 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct DomainsClient2 { + pub http_client: HttpClient, +} + +impl DomainsClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains list --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &PodsDomainsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + request: &PodsDomainsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/domains", pod_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains create --pod-id --domain example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .create( + /// &PodsPodID("pod_id".to_string()), + /// &CreateDomainRequest { + /// domain: DomainName("domain".to_string()), + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + pod_id: &PodsPodId, + request: &CreateDomainRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/pods/{}/domains", pod_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains get --pod-id --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .get( + /// &PodsPodID("pod_id".to_string()), + /// &DomainID("domain_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + domain_id: &DomainId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/domains/{}", pod_id.0, domain_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains delete --pod-id --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .delete( + /// &PodsPodID("pod_id".to_string()), + /// &DomainID("domain_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + domain_id: &DomainId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/pods/{}/domains/{}", pod_id.0, domain_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains update --pod-id --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .update( + /// &PodsPodID("pod_id".to_string()), + /// &DomainID("domain_id".to_string()), + /// &UpdateDomainRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + pod_id: &PodsPodId, + domain_id: &DomainId, + request: &UpdateDomainRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/pods/{}/domains/{}", pod_id.0, domain_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains get-zone-file --pod-id --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .get_zone_file( + /// &PodsPodID("pod_id".to_string()), + /// &DomainID("domain_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_zone_file( + &self, + pod_id: &PodsPodId, + domain_id: &DomainId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/domains/{}/zone-file", pod_id.0, domain_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods domains verify --pod-id --domain-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .domains + /// .verify( + /// &PodsPodID("pod_id".to_string()), + /// &DomainID("domain_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn verify( + &self, + pod_id: &PodsPodId, + domain_id: &DomainId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::POST, + &format!("v0/pods/{}/domains/{}/verify", pod_id.0, domain_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/drafts/mod.rs b/agentmail-sdk/src/api/resources/pods/drafts/mod.rs new file mode 100644 index 0000000..8d8143e --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/drafts/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_drafts; +pub use pods_drafts::DraftsClient3; diff --git a/agentmail-sdk/src/api/resources/pods/drafts/pods_drafts.rs b/agentmail-sdk/src/api/resources/pods/drafts/pods_drafts.rs new file mode 100644 index 0000000..acbd365 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/drafts/pods_drafts.rs @@ -0,0 +1,193 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct DraftsClient3 { + pub http_client: HttpClient, +} + +impl DraftsClient3 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods drafts list --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .drafts + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &PodsDraftsListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + request: &PodsDraftsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/drafts", pod_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods drafts get --pod-id --draft-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .drafts + /// .get( + /// &PodsPodID("pod_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + draft_id: &DraftId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/drafts/{}", pod_id.0, draft_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods drafts get-attachment --pod-id --draft-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .drafts + /// .get_attachment( + /// &PodsPodID("pod_id".to_string()), + /// &DraftID("draft_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + pod_id: &PodsPodId, + draft_id: &DraftId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/pods/{}/drafts/{}/attachments/{}", + pod_id.0, draft_id.0, attachment_id.0 + ), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/inboxes/mod.rs b/agentmail-sdk/src/api/resources/pods/inboxes/mod.rs new file mode 100644 index 0000000..b11f19b --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/inboxes/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_inboxes; +pub use pods_inboxes::InboxesClient2; diff --git a/agentmail-sdk/src/api/resources/pods/inboxes/pods_inboxes.rs b/agentmail-sdk/src/api/resources/pods/inboxes/pods_inboxes.rs new file mode 100644 index 0000000..a032c67 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/inboxes/pods_inboxes.rs @@ -0,0 +1,292 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct InboxesClient2 { + pub http_client: HttpClient, +} + +impl InboxesClient2 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods inboxes list --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .inboxes + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &PodsInboxesListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + request: &PodsInboxesListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/inboxes", pod_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods inboxes create --pod-id --username myagent --domain example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .inboxes + /// .create( + /// &PodsPodID("pod_id".to_string()), + /// &InboxesCreateInboxRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + pod_id: &PodsPodId, + request: &InboxesCreateInboxRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/pods/{}/inboxes", pod_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods inboxes get --pod-id --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .inboxes + /// .get( + /// &PodsPodID("pod_id".to_string()), + /// &InboxesInboxID("inbox_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + inbox_id: &InboxesInboxId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/inboxes/{}", pod_id.0, inbox_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods inboxes delete --pod-id --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .inboxes + /// .delete( + /// &PodsPodID("pod_id".to_string()), + /// &InboxesInboxID("inbox_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + inbox_id: &InboxesInboxId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/pods/{}/inboxes/{}", pod_id.0, inbox_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods inboxes update --pod-id --inbox-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .inboxes + /// .update( + /// &PodsPodID("pod_id".to_string()), + /// &InboxesInboxID("inbox_id".to_string()), + /// &InboxesUpdateInboxRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + pod_id: &PodsPodId, + inbox_id: &InboxesInboxId, + request: &InboxesUpdateInboxRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/pods/{}/inboxes/{}", pod_id.0, inbox_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/lists/mod.rs b/agentmail-sdk/src/api/resources/pods/lists/mod.rs new file mode 100644 index 0000000..cbcb057 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/lists/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_lists; +pub use pods_lists::ListsClient3; diff --git a/agentmail-sdk/src/api/resources/pods/lists/pods_lists.rs b/agentmail-sdk/src/api/resources/pods/lists/pods_lists.rs new file mode 100644 index 0000000..6969ba7 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/lists/pods_lists.rs @@ -0,0 +1,259 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ListsClient3 { + pub http_client: HttpClient, +} + +impl ListsClient3 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods lists list --pod-id --direction --type + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .lists + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &PodsListsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + direction: &Direction, + type_: &ListType, + request: &PodsListsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/lists/{}/{}", pod_id.0, direction, type_), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods lists create --pod-id --direction --type --entry user@example.com + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .lists + /// .create( + /// &PodsPodID("pod_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &CreateListEntryRequest { + /// entry: "entry".to_string(), + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + pod_id: &PodsPodId, + direction: &Direction, + type_: &ListType, + request: &CreateListEntryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/pods/{}/lists/{}/{}", pod_id.0, direction, type_), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods lists get --pod-id --direction --type --entry + /// ``` + /// + /// # Arguments + /// + /// * `entry` - Email address or domain. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .lists + /// .get( + /// &PodsPodID("pod_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &"entry".to_string(), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + direction: &Direction, + type_: &ListType, + entry: &str, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/pods/{}/lists/{}/{}/{}", + pod_id.0, direction, type_, entry + ), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods lists delete --pod-id --direction --type --entry + /// ``` + /// + /// # Arguments + /// + /// * `entry` - Email address or domain. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .lists + /// .delete( + /// &PodsPodID("pod_id".to_string()), + /// &Direction::Send, + /// &ListType::Allow, + /// &"entry".to_string(), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + direction: &Direction, + type_: &ListType, + entry: &str, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!( + "v0/pods/{}/lists/{}/{}/{}", + pod_id.0, direction, type_, entry + ), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/metrics/mod.rs b/agentmail-sdk/src/api/resources/pods/metrics/mod.rs new file mode 100644 index 0000000..eead032 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/metrics/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_metrics; +pub use pods_metrics::MetricsClient3; diff --git a/agentmail-sdk/src/api/resources/pods/metrics/pods_metrics.rs b/agentmail-sdk/src/api/resources/pods/metrics/pods_metrics.rs new file mode 100644 index 0000000..ec25a63 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/metrics/pods_metrics.rs @@ -0,0 +1,158 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct MetricsClient3 { + pub http_client: HttpClient, +} + +impl MetricsClient3 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Counts of email events (sent, delivered, bounced, etc.) over time for + /// the pod. Defaults to the last 24 hours; `start` must be within the last + /// 90 days, and a future `end` is clamped to now. Omit `period` for + /// individual event counts, or set it to sum counts into buckets of that + /// many seconds. + /// + /// **CLI:** + /// ```bash + /// agentmail pods metrics query-events --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .metrics + /// .query_events( + /// &PodsPodID("pod_id".to_string()), + /// &PodsMetricsQueryEventsQueryRequest { + /// event_types: vec![], + /// start: None, + /// end: None, + /// period: None, + /// limit: None, + /// descending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn query_events( + &self, + pod_id: &PodsPodId, + request: &PodsMetricsQueryEventsQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/metrics/events", pod_id.0), + None, + QueryBuilder::new() + .serialize_array("event_types", request.event_types.clone()) + .serialize("start", request.start.clone()) + .serialize("end", request.end.clone()) + .serialize("period", request.period.clone()) + .serialize("limit", request.limit.clone()) + .serialize("descending", request.descending.clone()) + .build(), + options, + ) + .await + } + + /// Cumulative usage series for the pod. Each point is the running total of + /// the usage type at that timestamp, not the change within the bucket. + /// Pod-scoped queries carry every usage type except `pod_count`; requested + /// types that don't apply to the scope are ignored. Defaults to the last + /// 24 hours; `start` must be within the last 90 days, and a future `end` + /// is clamped to now. The range divided by `period` must not exceed 1000 + /// buckets. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .metrics + /// .query_usage( + /// &PodsPodID("pod_id".to_string()), + /// &PodsMetricsQueryUsageQueryRequest { + /// usage_types: vec![], + /// start: None, + /// end: None, + /// period: None, + /// limit: None, + /// descending: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn query_usage( + &self, + pod_id: &PodsPodId, + request: &PodsMetricsQueryUsageQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/metrics/usage", pod_id.0), + None, + QueryBuilder::new() + .serialize_array("usage_types", request.usage_types.clone()) + .serialize("start", request.start.clone()) + .serialize("end", request.end.clone()) + .serialize("period", request.period.clone()) + .serialize("limit", request.limit.clone()) + .serialize("descending", request.descending.clone()) + .build(), + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/mod.rs b/agentmail-sdk/src/api/resources/pods/mod.rs new file mode 100644 index 0000000..25fe2f1 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/mod.rs @@ -0,0 +1,249 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub mod api_keys; +pub use api_keys::ApiKeysClient3; +pub mod domains; +pub use domains::DomainsClient2; +pub mod drafts; +pub use drafts::DraftsClient3; +pub mod inboxes; +pub use inboxes::InboxesClient2; +pub mod lists; +pub use lists::ListsClient3; +pub mod metrics; +pub use metrics::MetricsClient3; +pub mod threads; +pub use threads::ThreadsClient3; +pub mod webhooks; +pub use webhooks::WebhooksClient3; +pub struct PodsClient { + pub http_client: HttpClient, + pub api_keys: ApiKeysClient3, + pub domains: DomainsClient2, + pub drafts: DraftsClient3, + pub inboxes: InboxesClient2, + pub lists: ListsClient3, + pub metrics: MetricsClient3, + pub threads: ThreadsClient3, + pub webhooks: WebhooksClient3, +} + +impl PodsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + api_keys: ApiKeysClient3::new(config.clone())?, + domains: DomainsClient2::new(config.clone())?, + drafts: DraftsClient3::new(config.clone())?, + inboxes: InboxesClient2::new(config.clone())?, + lists: ListsClient3::new(config.clone())?, + metrics: MetricsClient3::new(config.clone())?, + threads: ThreadsClient3::new(config.clone())?, + webhooks: WebhooksClient3::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods list + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .list( + /// &PodsListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &PodsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/pods", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods create --client-id my-pod + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .create( + /// &PodsCreatePodRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + request: &PodsCreatePodRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/pods", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods get --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .get(&PodsPodID("pod_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}", pod_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods delete --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .delete(&PodsPodID("pod_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/pods/{}", pod_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/threads/mod.rs b/agentmail-sdk/src/api/resources/pods/threads/mod.rs new file mode 100644 index 0000000..6254cf0 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/threads/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_threads; +pub use pods_threads::ThreadsClient3; diff --git a/agentmail-sdk/src/api/resources/pods/threads/pods_threads.rs b/agentmail-sdk/src/api/resources/pods/threads/pods_threads.rs new file mode 100644 index 0000000..5b2bd08 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/threads/pods_threads.rs @@ -0,0 +1,392 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ThreadsClient3 { + pub http_client: HttpClient, +} + +impl ThreadsClient3 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Lists threads in the pod, most recent first. Pass `senders`, + /// `recipients`, or `subject` to filter by substring. Filtered requests are + /// served by search, which caps `limit` at 100. For relevance-ranked + /// full-text search, use `Search Threads`. + /// + /// **CLI:** + /// ```bash + /// agentmail pods threads list --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `senders` - Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. + /// * `recipients` - Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. + /// * `subject` - Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .threads + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &PodsThreadsListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// include_spam: None, + /// include_blocked: None, + /// include_unauthenticated: None, + /// include_trash: None, + /// senders: None, + /// recipients: None, + /// subject: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + request: &PodsThreadsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/threads", pod_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .serialize("include_spam", request.include_spam.clone()) + .serialize("include_blocked", request.include_blocked.clone()) + .serialize( + "include_unauthenticated", + request.include_unauthenticated.clone(), + ) + .serialize("include_trash", request.include_trash.clone()) + .serialize("senders", request.senders.clone()) + .serialize("recipients", request.recipients.clone()) + .serialize("subject", request.subject.clone()) + .build(), + options, + ) + .await + } + + /// Full-text search across threads in the pod, ranked by relevance. The + /// query is matched against senders, recipients, and subject (substring) + /// and the message body (tokenized full text). Spam, trash, blocked, and + /// unauthenticated threads are always excluded. `limit` cannot exceed 100. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .threads + /// .search( + /// &PodsPodID("pod_id".to_string()), + /// &PodsThreadsSearchQueryRequest { + /// q: Query("q".to_string()), + /// limit: None, + /// page_token: None, + /// before: None, + /// after: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn search( + &self, + pod_id: &PodsPodId, + request: &PodsThreadsSearchQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/threads/search", pod_id.0), + None, + QueryBuilder::new() + .serialize("q", Some(request.q.clone())) + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods threads get --pod-id --thread-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .threads + /// .get( + /// &PodsPodID("pod_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + thread_id: &ThreadId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/threads/{}", pod_id.0, thread_id.0), + None, + None, + options, + ) + .await + } + + /// Permanently deletes a thread and all of its messages. + /// + /// **CLI:** + /// ```bash + /// agentmail pods threads delete --pod-id --thread-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .threads + /// .delete( + /// &PodsPodID("pod_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + thread_id: &ThreadId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/pods/{}/threads/{}", pod_id.0, thread_id.0), + None, + None, + options, + ) + .await + } + + /// Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .threads + /// .update( + /// &PodsPodID("pod_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// &UpdateThreadRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + pod_id: &PodsPodId, + thread_id: &ThreadId, + request: &UpdateThreadRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/pods/{}/threads/{}", pod_id.0, thread_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods threads get-attachment --pod-id --thread-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .threads + /// .get_attachment( + /// &PodsPodID("pod_id".to_string()), + /// &ThreadID("thread_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + pod_id: &PodsPodId, + thread_id: &ThreadId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!( + "v0/pods/{}/threads/{}/attachments/{}", + pod_id.0, thread_id.0, attachment_id.0 + ), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/pods/webhooks/mod.rs b/agentmail-sdk/src/api/resources/pods/webhooks/mod.rs new file mode 100644 index 0000000..682afed --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/webhooks/mod.rs @@ -0,0 +1,2 @@ +pub mod pods_webhooks; +pub use pods_webhooks::WebhooksClient3; diff --git a/agentmail-sdk/src/api/resources/pods/webhooks/pods_webhooks.rs b/agentmail-sdk/src/api/resources/pods/webhooks/pods_webhooks.rs new file mode 100644 index 0000000..c725e95 --- /dev/null +++ b/agentmail-sdk/src/api/resources/pods/webhooks/pods_webhooks.rs @@ -0,0 +1,410 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct WebhooksClient3 { + pub http_client: HttpClient, +} + +impl WebhooksClient3 { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail pods webhooks list --pod-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .list( + /// &PodsPodID("pod_id".to_string()), + /// &PodsWebhooksListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + pod_id: &PodsPodId, + request: &PodsWebhooksListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/webhooks", pod_id.0), + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// Create a webhook scoped to this pod. + /// + /// **CLI:** + /// ```bash + /// agentmail pods webhooks create --pod-id --url https://example.com/webhook --event-types message.received + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .create( + /// &PodsPodID("pod_id".to_string()), + /// &WebhooksCreatePodWebhookRequest { + /// webhooks_create_inbox_webhook_request_fields: WebhooksCreateInboxWebhookRequest { + /// url: WebhooksURL("url".to_string()), + /// event_types: WebhooksCreateWebhookEventTypes(EventTypes(vec![ + /// EventType::MessageReceived, + /// ])), + /// ..Default::default() + /// }, + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + pod_id: &PodsPodId, + request: &WebhooksCreatePodWebhookRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + &format!("v0/pods/{}/webhooks", pod_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods webhooks get --pod-id --webhook-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .get( + /// &PodsPodID("pod_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + pod_id: &PodsPodId, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/webhooks/{}", pod_id.0, webhook_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods webhooks delete --pod-id --webhook-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .delete( + /// &PodsPodID("pod_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + pod_id: &PodsPodId, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/pods/{}/webhooks/{}", pod_id.0, webhook_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail pods webhooks update --pod-id --webhook-id --add-inbox-ids + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .update( + /// &PodsPodID("pod_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// &WebhooksUpdatePodWebhookRequest { + /// webhooks_update_inbox_webhook_request_fields: WebhooksUpdateInboxWebhookRequest { + /// ..Default::default() + /// }, + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + pod_id: &PodsPodId, + webhook_id: &WebhooksWebhookId, + request: &WebhooksUpdatePodWebhookRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/pods/{}/webhooks/{}", pod_id.0, webhook_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// List the names of custom HTTP headers included with deliveries to this pod-scoped webhook. + /// Header values are write-only and are never returned. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .get_headers( + /// &PodsPodID("pod_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_headers( + &self, + pod_id: &PodsPodId, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/pods/{}/webhooks/{}/headers", pod_id.0, webhook_id.0), + None, + None, + options, + ) + .await + } + + /// Atomically set, replace, or remove custom HTTP headers included with deliveries to this + /// pod-scoped webhook. Header values remain write-only. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .pods + /// .webhooks + /// .update_headers( + /// &PodsPodID("pod_id".to_string()), + /// &WebhooksWebhookID("webhook_id".to_string()), + /// &WebhooksUpdateWebhookHeadersRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update_headers( + &self, + pod_id: &PodsPodId, + webhook_id: &WebhooksWebhookId, + request: &WebhooksUpdateWebhookHeadersRequest, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/pods/{}/webhooks/{}/headers", pod_id.0, webhook_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/threads/mod.rs b/agentmail-sdk/src/api/resources/threads/mod.rs new file mode 100644 index 0000000..a2b1539 --- /dev/null +++ b/agentmail-sdk/src/api/resources/threads/mod.rs @@ -0,0 +1,2 @@ +pub mod threads; +pub use threads::ThreadsClient; diff --git a/agentmail-sdk/src/api/resources/threads/threads.rs b/agentmail-sdk/src/api/resources/threads/threads.rs new file mode 100644 index 0000000..3cb6756 --- /dev/null +++ b/agentmail-sdk/src/api/resources/threads/threads.rs @@ -0,0 +1,367 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct ThreadsClient { + pub http_client: HttpClient, +} + +impl ThreadsClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// Lists threads, most recent first. Pass `senders`, `recipients`, or + /// `subject` to filter by substring. Filtered requests are served by + /// search, which caps `limit` at 100. For relevance-ranked full-text + /// search across senders, recipients, subject, and message body, use + /// `Search Threads`. + /// + /// **CLI:** + /// ```bash + /// agentmail threads list + /// ``` + /// + /// # Arguments + /// + /// * `senders` - Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. + /// * `recipients` - Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. + /// * `subject` - Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .threads + /// .list( + /// &ThreadsListQueryRequest { + /// limit: None, + /// page_token: None, + /// labels: vec![], + /// before: None, + /// after: None, + /// ascending: None, + /// include_spam: None, + /// include_blocked: None, + /// include_unauthenticated: None, + /// include_trash: None, + /// senders: None, + /// recipients: None, + /// subject: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &ThreadsListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/threads", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .string_array("labels", request.labels.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .serialize("ascending", request.ascending.clone()) + .serialize("include_spam", request.include_spam.clone()) + .serialize("include_blocked", request.include_blocked.clone()) + .serialize( + "include_unauthenticated", + request.include_unauthenticated.clone(), + ) + .serialize("include_trash", request.include_trash.clone()) + .serialize("senders", request.senders.clone()) + .serialize("recipients", request.recipients.clone()) + .serialize("subject", request.subject.clone()) + .build(), + options, + ) + .await + } + + /// Full-text search across threads in the organization, ranked by + /// relevance. The query is matched against senders, recipients, and + /// subject (substring) and the message body (tokenized full text). Spam, + /// trash, blocked, and unauthenticated threads are always excluded. + /// `limit` cannot exceed 100. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .threads + /// .search( + /// &ThreadsSearchQueryRequest { + /// q: Query("q".to_string()), + /// limit: None, + /// page_token: None, + /// before: None, + /// after: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn search( + &self, + request: &ThreadsSearchQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/threads/search", + None, + QueryBuilder::new() + .serialize("q", Some(request.q.clone())) + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("before", request.before.clone()) + .serialize("after", request.after.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail threads get --thread-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .threads + /// .get(&ThreadID("thread_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + thread_id: &ThreadId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/threads/{}", thread_id.0), + None, + None, + options, + ) + .await + } + + /// Permanently deletes a thread and all of its messages. + /// + /// **CLI:** + /// ```bash + /// agentmail threads delete --thread-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .threads + /// .delete(&ThreadID("thread_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + thread_id: &ThreadId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/threads/{}", thread_id.0), + None, + None, + options, + ) + .await + } + + /// Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .threads + /// .update( + /// &ThreadID("thread_id".to_string()), + /// &UpdateThreadRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + thread_id: &ThreadId, + request: &UpdateThreadRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/threads/{}", thread_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail threads get-attachment --thread-id --attachment-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .threads + /// .get_attachment( + /// &ThreadID("thread_id".to_string()), + /// &AttachmentID("attachment_id".to_string()), + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn get_attachment( + &self, + thread_id: &ThreadId, + attachment_id: &AttachmentId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/threads/{}/attachments/{}", thread_id.0, attachment_id.0), + None, + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/api/resources/webhooks/mod.rs b/agentmail-sdk/src/api/resources/webhooks/mod.rs new file mode 100644 index 0000000..a2a50e8 --- /dev/null +++ b/agentmail-sdk/src/api/resources/webhooks/mod.rs @@ -0,0 +1,2 @@ +pub mod webhooks; +pub use webhooks::WebhooksClient; diff --git a/agentmail-sdk/src/api/resources/webhooks/webhooks.rs b/agentmail-sdk/src/api/resources/webhooks/webhooks.rs new file mode 100644 index 0000000..c410b7d --- /dev/null +++ b/agentmail-sdk/src/api/resources/webhooks/webhooks.rs @@ -0,0 +1,378 @@ +use crate::api::*; +use crate::{ApiError, ClientConfig, HttpClient, QueryBuilder, RequestOptions}; +use reqwest::Method; + +pub struct WebhooksClient { + pub http_client: HttpClient, +} + +impl WebhooksClient { + pub fn new(config: ClientConfig) -> Result { + Ok(Self { + http_client: HttpClient::new(config.clone())?, + }) + } + + /// **CLI:** + /// ```bash + /// agentmail webhooks list + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .list( + /// &WebhooksListQueryRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn list( + &self, + request: &WebhooksListQueryRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + "v0/webhooks", + None, + QueryBuilder::new() + .serialize("limit", request.limit.clone()) + .serialize("page_token", request.page_token.clone()) + .serialize("ascending", request.ascending.clone()) + .build(), + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail webhooks create --url https://example.com/webhook --event-types message.received + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .create( + /// &WebhooksCreateWebhookRequest { + /// url: WebhooksURL("url".to_string()), + /// event_types: WebhooksCreateWebhookEventTypes(EventTypes(vec![ + /// EventType::MessageReceived, + /// ])), + /// inbox_ids: None, + /// client_id: None, + /// headers: None, + /// pod_ids: None, + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn create( + &self, + request: &WebhooksCreateWebhookRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::POST, + "v0/webhooks", + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail webhooks get --webhook-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .get(&WebhooksWebhookID("webhook_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get( + &self, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/webhooks/{}", webhook_id.0), + None, + None, + options, + ) + .await + } + + /// **CLI:** + /// ```bash + /// agentmail webhooks delete --webhook-id + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .delete(&WebhooksWebhookID("webhook_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn delete( + &self, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::DELETE, + &format!("v0/webhooks/{}", webhook_id.0), + None, + None, + options, + ) + .await + } + + /// Update inbox or pod subscriptions, or replace the webhook's `event_types` in full when you pass a + /// non-empty `event_types` array (see request field docs). Inbox and pod changes use add/remove lists. + /// + /// **CLI:** + /// ```bash + /// agentmail webhooks update --webhook-id --add-inbox-ids + /// ``` + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .update( + /// &WebhooksWebhookID("webhook_id".to_string()), + /// &WebhooksUpdateWebhookRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update( + &self, + webhook_id: &WebhooksWebhookId, + request: &WebhooksUpdateWebhookRequest, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/webhooks/{}", webhook_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } + + /// List the names of custom HTTP headers included with deliveries to this webhook. Header values are + /// write-only and are never returned. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// JSON response from the API + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .get_headers(&WebhooksWebhookID("webhook_id".to_string()), None) + /// .await; + /// } + /// ``` + pub async fn get_headers( + &self, + webhook_id: &WebhooksWebhookId, + options: Option, + ) -> Result { + self.http_client + .execute_request( + Method::GET, + &format!("v0/webhooks/{}/headers", webhook_id.0), + None, + None, + options, + ) + .await + } + + /// Atomically set, replace, or remove custom HTTP headers included with deliveries to this webhook. + /// Header values remain write-only. + /// + /// # Arguments + /// + /// * `options` - Additional request options such as headers, timeout, etc. + /// + /// # Returns + /// + /// Empty response + /// + /// # Examples + /// + /// ```no_run + /// use agentmail_sdk::prelude::*; + /// + /// #[tokio::main] + /// async fn main() { + /// let config = ClientConfig { + /// token: Some("".to_string()), + /// ..Default::default() + /// }; + /// let client = AgentmailClient::new(config).expect("Failed to build client"); + /// client + /// .webhooks + /// .update_headers( + /// &WebhooksWebhookID("webhook_id".to_string()), + /// &WebhooksUpdateWebhookHeadersRequest { + /// ..Default::default() + /// }, + /// None, + /// ) + /// .await; + /// } + /// ``` + pub async fn update_headers( + &self, + webhook_id: &WebhooksWebhookId, + request: &WebhooksUpdateWebhookHeadersRequest, + options: Option, + ) -> Result<(), ApiError> { + self.http_client + .execute_request( + Method::PATCH, + &format!("v0/webhooks/{}/headers", webhook_id.0), + Some(serde_json::to_value(request).map_err(ApiError::Serialization)?), + None, + options, + ) + .await + } +} diff --git a/agentmail-sdk/src/client.rs b/agentmail-sdk/src/client.rs new file mode 100644 index 0000000..d9907a3 --- /dev/null +++ b/agentmail-sdk/src/client.rs @@ -0,0 +1,231 @@ +use crate::api::resources::ApiClient; +use crate::{ApiError, ClientConfig}; +use std::collections::HashMap; +use std::time::Duration; +/// Builder for creating API clients with custom configuration +pub struct ApiClientBuilder { + config: ClientConfig, +} +impl Default for ApiClientBuilder { + fn default() -> Self { + Self { + config: ClientConfig::default(), + } + } +} +impl ApiClientBuilder { + /// Create a new builder with the specified base URL + pub fn new(base_url: impl Into) -> Self { + let mut config = ClientConfig::default(); + config.base_url = base_url.into(); + Self { config } + } + + /// Set the API key for authentication + pub fn api_key(mut self, key: impl Into) -> Self { + self.config.api_key = Some(key.into()); + self + } + + /// Set the bearer token for authentication + pub fn token(mut self, token: impl Into) -> Self { + self.config.token = Some(token.into()); + self + } + + /// Set the username for basic authentication + pub fn username(mut self, username: impl Into) -> Self { + self.config.username = Some(username.into()); + self + } + + /// Set the password for basic authentication + pub fn password(mut self, password: impl Into) -> Self { + self.config.password = Some(password.into()); + self + } + + /// Set the OAuth client ID for client credentials authentication + pub fn client_id(mut self, client_id: impl Into) -> Self { + self.config.client_id = Some(client_id.into()); + self + } + + /// Set the OAuth client secret for client credentials authentication + pub fn client_secret(mut self, client_secret: impl Into) -> Self { + self.config.client_secret = Some(client_secret.into()); + self + } + + /// Set OAuth credentials (client_id and client_secret) for client credentials authentication + pub fn oauth_credentials( + mut self, + client_id: impl Into, + client_secret: impl Into, + ) -> Self { + self.config.client_id = Some(client_id.into()); + self.config.client_secret = Some(client_secret.into()); + self + } + + /// Set the request timeout + pub fn timeout(mut self, timeout: Duration) -> Self { + self.config.timeout = timeout; + self + } + + /// Set the maximum number of retries + pub fn max_retries(mut self, retries: u32) -> Self { + self.config.max_retries = retries; + self + } + + /// Add a custom header + pub fn custom_header(mut self, key: impl Into, value: impl Into) -> Self { + self.config.custom_headers.insert(key.into(), value.into()); + self + } + + /// Add multiple custom headers + pub fn custom_headers(mut self, headers: HashMap) -> Self { + self.config.custom_headers.extend(headers); + self + } + + /// Set the user agent + pub fn user_agent(mut self, user_agent: impl Into) -> Self { + self.config.user_agent = user_agent.into(); + self + } + + /// Build the client with validation + pub fn build(self) -> Result { + ApiClient::new(self.config) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_sets_base_url() { + let builder = ApiClientBuilder::new("https://api.example.com"); + assert_eq!(builder.config.base_url, "https://api.example.com"); + } + + #[test] + fn test_api_key() { + let builder = ApiClientBuilder::new("https://api.example.com").api_key("my-key"); + assert_eq!(builder.config.api_key, Some("my-key".to_string())); + } + + #[test] + fn test_token() { + let builder = ApiClientBuilder::new("https://api.example.com").token("my-token"); + assert_eq!(builder.config.token, Some("my-token".to_string())); + } + + #[test] + fn test_username() { + let builder = ApiClientBuilder::new("https://api.example.com").username("user"); + assert_eq!(builder.config.username, Some("user".to_string())); + } + + #[test] + fn test_password() { + let builder = ApiClientBuilder::new("https://api.example.com").password("pass"); + assert_eq!(builder.config.password, Some("pass".to_string())); + } + + #[test] + fn test_client_id() { + let builder = ApiClientBuilder::new("https://api.example.com").client_id("cid"); + assert_eq!(builder.config.client_id, Some("cid".to_string())); + } + + #[test] + fn test_client_secret() { + let builder = ApiClientBuilder::new("https://api.example.com").client_secret("secret"); + assert_eq!(builder.config.client_secret, Some("secret".to_string())); + } + + #[test] + fn test_oauth_credentials() { + let builder = + ApiClientBuilder::new("https://api.example.com").oauth_credentials("cid", "secret"); + assert_eq!(builder.config.client_id, Some("cid".to_string())); + assert_eq!(builder.config.client_secret, Some("secret".to_string())); + } + + #[test] + fn test_timeout() { + let builder = + ApiClientBuilder::new("https://api.example.com").timeout(Duration::from_secs(120)); + assert_eq!(builder.config.timeout, Duration::from_secs(120)); + } + + #[test] + fn test_max_retries() { + let builder = ApiClientBuilder::new("https://api.example.com").max_retries(5); + assert_eq!(builder.config.max_retries, 5); + } + + #[test] + fn test_custom_header() { + let builder = + ApiClientBuilder::new("https://api.example.com").custom_header("X-Custom", "value"); + assert_eq!( + builder.config.custom_headers.get("X-Custom"), + Some(&"value".to_string()) + ); + } + + #[test] + fn test_custom_headers_multiple() { + let mut headers = HashMap::new(); + headers.insert("X-One".to_string(), "1".to_string()); + headers.insert("X-Two".to_string(), "2".to_string()); + let builder = ApiClientBuilder::new("https://api.example.com").custom_headers(headers); + assert_eq!( + builder.config.custom_headers.get("X-One"), + Some(&"1".to_string()) + ); + assert_eq!( + builder.config.custom_headers.get("X-Two"), + Some(&"2".to_string()) + ); + } + + #[test] + fn test_user_agent() { + let builder = ApiClientBuilder::new("https://api.example.com").user_agent("my-sdk/1.0"); + assert_eq!(builder.config.user_agent, "my-sdk/1.0"); + } + + #[test] + fn test_full_builder_chain() { + let builder = ApiClientBuilder::new("https://api.example.com") + .api_key("key") + .token("tok") + .username("user") + .password("pass") + .timeout(Duration::from_secs(60)) + .max_retries(3) + .custom_header("X-Foo", "bar") + .user_agent("test/1.0"); + assert_eq!(builder.config.base_url, "https://api.example.com"); + assert_eq!(builder.config.api_key, Some("key".to_string())); + assert_eq!(builder.config.token, Some("tok".to_string())); + assert_eq!(builder.config.username, Some("user".to_string())); + assert_eq!(builder.config.password, Some("pass".to_string())); + assert_eq!(builder.config.timeout, Duration::from_secs(60)); + assert_eq!(builder.config.max_retries, 3); + assert_eq!(builder.config.user_agent, "test/1.0"); + } + + #[test] + fn test_build_succeeds() { + let result = ApiClientBuilder::new("https://api.example.com").build(); + assert!(result.is_ok()); + } +} diff --git a/agentmail-sdk/src/config.rs b/agentmail-sdk/src/config.rs new file mode 100644 index 0000000..f2ad191 --- /dev/null +++ b/agentmail-sdk/src/config.rs @@ -0,0 +1,42 @@ +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub struct ClientConfig { + pub base_url: String, + pub api_key: Option, + pub token: Option, + pub username: Option, + pub password: Option, + pub client_id: Option, + pub client_secret: Option, + pub oauth_token_endpoint: Option, + pub oauth_token_exchange: Option, + pub timeout: Duration, + pub max_retries: u32, + pub custom_headers: HashMap, + pub user_agent: String, +} +impl Default for ClientConfig { + fn default() -> Self { + Self { + base_url: String::new(), + api_key: None, + token: None, + username: None, + password: None, + client_id: None, + client_secret: None, + oauth_token_endpoint: None, + oauth_token_exchange: None, + timeout: Duration::from_secs(60), + max_retries: 3, + custom_headers: HashMap::from([ + ("X-Fern-Language".to_string(), "Rust".to_string()), + ("X-Fern-SDK-Name".to_string(), "agentmail_sdk".to_string()), + ("X-Fern-SDK-Version".to_string(), "0.1.0".to_string()), + ]), + user_agent: "Api Rust SDK".to_string(), + } + } +} diff --git a/agentmail-sdk/src/core/flexible_datetime.rs b/agentmail-sdk/src/core/flexible_datetime.rs new file mode 100644 index 0000000..e5572d1 --- /dev/null +++ b/agentmail-sdk/src/core/flexible_datetime.rs @@ -0,0 +1,270 @@ +//! Flexible datetime parsing module +//! +//! This module provides serde helpers for parsing datetime strings that may or may not +//! include a timezone suffix. It accepts both RFC3339 format (with Z or +00:00 suffix) +//! and ISO 8601 format without timezone (assuming UTC). +//! +//! Supported formats: +//! - `2024-01-15T09:30:00Z` (RFC3339 with Z) +//! - `2024-01-15T09:30:00+00:00` (RFC3339 with offset) +//! - `2024-01-15T09:30:00` (ISO 8601 without timezone, assumes UTC) +//! - `2024-01-15T09:30:00.123Z` (with fractional seconds and Z) +//! - `2024-01-15T09:30:00.123` (with fractional seconds, no timezone) +//! +//! Two submodules are provided: +//! - `utc`: Parses into `DateTime`, converting all datetimes to UTC +//! - `offset`: Parses into `DateTime`, preserving original timezone + +/// Module for DateTime with flexible parsing - converts all datetimes to UTC +pub mod utc { + use chrono::{DateTime, NaiveDateTime, Utc}; + use serde::{self, Deserialize, Deserializer, Serializer}; + + /// Serialize a DateTime to RFC3339 format + pub fn serialize(date: &DateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.to_rfc3339()) + } + + /// Deserialize a datetime string that may or may not include a timezone suffix. + /// If no timezone is present, UTC is assumed. All datetimes are converted to UTC. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + parse_flexible_datetime(&s).map_err(serde::de::Error::custom) + } + + /// Parse a datetime string flexibly, accepting both RFC3339 and plain ISO 8601 formats. + fn parse_flexible_datetime(s: &str) -> Result, String> { + // First, try parsing as RFC3339 (with timezone) + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + + // Try parsing as NaiveDateTime (without timezone) and assume UTC + // Try with fractional seconds first + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return Ok(naive.and_utc()); + } + + // Try without fractional seconds + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { + return Ok(naive.and_utc()); + } + + Err(format!( + "Failed to parse datetime '{}'. Expected RFC3339 format (e.g., '2024-01-15T09:30:00Z') \ + or ISO 8601 format (e.g., '2024-01-15T09:30:00')", + s + )) + } + + /// Module for optional DateTime fields with flexible parsing + pub mod option { + use super::*; + + pub fn serialize(date: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match date { + Some(dt) => serializer.serialize_some(&dt.to_rfc3339()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => parse_flexible_datetime(&s) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_parse_rfc3339_with_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00Z"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_rfc3339_with_offset() { + let result = parse_flexible_datetime("2024-01-15T09:30:00+00:00"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_without_timezone() { + let result = parse_flexible_datetime("2024-01-15T09:30:00"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_with_fractional_seconds() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_with_fractional_seconds_and_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123Z"); + assert!(result.is_ok()); + } + } +} + +/// Module for DateTime with flexible parsing - preserves original timezone +pub mod offset { + use chrono::{DateTime, FixedOffset, NaiveDateTime}; + use serde::{self, Deserialize, Deserializer, Serializer}; + + /// Serialize a DateTime to RFC3339 format + pub fn serialize(date: &DateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.to_rfc3339()) + } + + /// Deserialize a datetime string that may or may not include a timezone suffix. + /// If no timezone is present, UTC (+00:00) is assumed. + /// The original timezone offset is preserved when present. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + parse_flexible_datetime(&s).map_err(serde::de::Error::custom) + } + + /// Parse a datetime string flexibly, accepting both RFC3339 and plain ISO 8601 formats. + /// Preserves the original timezone offset when present, assumes UTC when not. + fn parse_flexible_datetime(s: &str) -> Result, String> { + // First, try parsing as RFC3339 (with timezone) - this preserves the original offset + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt); + } + + // Try parsing as NaiveDateTime (without timezone) and assume UTC (+00:00) + let utc_offset = FixedOffset::east_opt(0).unwrap(); + + // Try with fractional seconds first + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return Ok(naive.and_local_timezone(utc_offset).unwrap()); + } + + // Try without fractional seconds + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { + return Ok(naive.and_local_timezone(utc_offset).unwrap()); + } + + Err(format!( + "Failed to parse datetime '{}'. Expected RFC3339 format (e.g., '2024-01-15T09:30:00Z') \ + or ISO 8601 format (e.g., '2024-01-15T09:30:00')", + s + )) + } + + /// Module for optional DateTime fields with flexible parsing + pub mod option { + use super::*; + + pub fn serialize( + date: &Option>, + serializer: S, + ) -> Result + where + S: Serializer, + { + match date { + Some(dt) => serializer.serialize_some(&dt.to_rfc3339()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>( + deserializer: D, + ) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => parse_flexible_datetime(&s) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_parse_rfc3339_with_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00Z"); + assert!(result.is_ok()); + let dt = result.unwrap(); + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_parse_rfc3339_with_offset() { + let result = parse_flexible_datetime("2024-01-15T09:30:00-05:00"); + assert!(result.is_ok()); + let dt = result.unwrap(); + // -05:00 = -5 * 3600 = -18000 seconds + assert_eq!(dt.offset().local_minus_utc(), -18000); + } + + #[test] + fn test_parse_without_timezone() { + let result = parse_flexible_datetime("2024-01-15T09:30:00"); + assert!(result.is_ok()); + let dt = result.unwrap(); + // Should assume UTC (+00:00) + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_parse_with_fractional_seconds() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123"); + assert!(result.is_ok()); + let dt = result.unwrap(); + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_parse_with_fractional_seconds_and_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123Z"); + assert!(result.is_ok()); + let dt = result.unwrap(); + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_preserves_positive_offset() { + let result = parse_flexible_datetime("2024-01-15T09:30:00+09:00"); + assert!(result.is_ok()); + let dt = result.unwrap(); + // +09:00 = 9 * 3600 = 32400 seconds + assert_eq!(dt.offset().local_minus_utc(), 32400); + } + } +} diff --git a/agentmail-sdk/src/core/http_client.rs b/agentmail-sdk/src/core/http_client.rs new file mode 100644 index 0000000..7165db0 --- /dev/null +++ b/agentmail-sdk/src/core/http_client.rs @@ -0,0 +1,1054 @@ +use crate::{join_url, ApiError, ClientConfig, OAuthTokenProvider, RequestOptions}; +use futures::{future::BoxFuture, Stream, StreamExt}; +use reqwest::{ + header::{HeaderMap, HeaderName, HeaderValue}, + Client, Method, Request, Response, +}; +use serde::de::DeserializeOwned; + +use std::{ + collections::HashMap, + pin::Pin, + str::FromStr, + sync::Arc, + task::{Context, Poll}, +}; + +/// A parsed HTTP response that includes the deserialized body along with +/// the HTTP status code and response headers. +#[derive(Debug)] +pub struct RawResponse { + /// The deserialized response body. + pub body: T, + /// The HTTP status code of the response. + pub status_code: u16, + /// The HTTP response headers. + pub headers: HeaderMap, +} + +/// A streaming byte stream for downloading files efficiently +pub struct ByteStream { + content_length: Option, + inner: Pin> + Send>>, +} + +impl ByteStream { + /// Create a new ByteStream from a Response + pub(crate) fn new(response: Response) -> Self { + let content_length = response.content_length(); + let stream = response.bytes_stream(); + + Self { + content_length, + inner: Box::pin(stream), + } + } + + /// Collect the entire stream into a `Vec` + /// + /// This consumes the stream and buffers all data into memory. + /// For large files, prefer using `try_next()` to process chunks incrementally. + /// + /// # Example + /// ```no_run + /// let stream = client.download_file().await?; + /// let bytes = stream.collect().await?; + /// ``` + pub async fn collect(mut self) -> Result, ApiError> { + let mut result = Vec::new(); + while let Some(chunk) = self.inner.next().await { + result.extend_from_slice(&chunk.map_err(ApiError::Network)?); + } + Ok(result) + } + + /// Get the next chunk from the stream + /// + /// Returns `Ok(Some(bytes))` if a chunk is available, + /// `Ok(None)` if the stream is finished, or an error. + /// + /// # Example + /// ```no_run + /// let mut stream = client.download_file().await?; + /// while let Some(chunk) = stream.try_next().await? { + /// process_chunk(&chunk); + /// } + /// ``` + pub async fn try_next(&mut self) -> Result, ApiError> { + match self.inner.next().await { + Some(Ok(bytes)) => Ok(Some(bytes)), + Some(Err(e)) => Err(ApiError::Network(e)), + None => Ok(None), + } + } + + /// Get the content length from response headers if available + pub fn content_length(&self) -> Option { + self.content_length + } +} + +impl Stream for ByteStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match self.inner.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => Poll::Ready(Some(Ok(bytes))), + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(ApiError::Network(e)))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +/// Trait for executing HTTP requests, enabling injection of custom +/// transport implementations (e.g., for CLI execution-sharing). +/// +/// When an external executor is provided, the SDK delegates raw HTTP +/// execution to it, allowing the caller's transport stack to handle +/// auth, retries, and TLS configuration. +#[doc(hidden)] +pub trait RequestExecutor: Send + Sync { + fn execute( + &self, + request: Request, + ) -> BoxFuture<'_, Result>>; +} + +/// Wire-level property-name mapping for the OAuth token exchange. +/// +/// The token endpoint's request/response contract varies between APIs (e.g. camelCase +/// `clientId`/`clientSecret`, an absent `grant_type`, or a non-standard `access_token` +/// field name). These names are resolved from the API's OAuth scheme in the IR so the +/// generated token fetch matches the endpoint's contract instead of hardcoding a shape. +#[derive(Debug, Clone)] +pub struct OAuthTokenExchangeConfig { + /// Request body field name carrying the client id (e.g. `"client_id"` or `"clientId"`). + pub client_id_property: String, + /// Request body field name carrying the client secret. + pub client_secret_property: String, + /// Additional static request body properties sent verbatim (e.g. + /// `{"grant_type": "client_credentials"}`). Empty when the token contract has none. + pub extra_request_properties: HashMap, + /// Response field name that holds the access token (e.g. `"access_token"`). + pub access_token_property: String, + /// Response field name that holds the token lifetime in seconds (e.g. `"expires_in"`). + pub expires_in_property: String, + /// Whether the token request body is `application/x-www-form-urlencoded` (per RFC 6749 + /// §4.4.2) instead of JSON. Resolved from the token endpoint's declared content type in + /// the API definition, so JSON token endpoints keep sending a JSON body. + pub form_encoded: bool, +} + +impl Default for OAuthTokenExchangeConfig { + fn default() -> Self { + Self { + client_id_property: "client_id".to_string(), + client_secret_property: "client_secret".to_string(), + extra_request_properties: HashMap::from([( + "grant_type".to_string(), + "client_credentials".to_string(), + )]), + access_token_property: "access_token".to_string(), + expires_in_property: "expires_in".to_string(), + form_encoded: false, + } + } +} + +/// Configuration for OAuth token fetching. +/// +/// This struct contains all the information needed to automatically fetch +/// and refresh OAuth tokens. +#[derive(Clone)] +pub struct OAuthConfig { + /// The OAuth token provider that manages token caching and refresh + pub token_provider: Arc, + /// The token endpoint path (e.g., "/token") + pub token_endpoint: String, + /// The request/response property-name mapping for the token exchange. + pub exchange: OAuthTokenExchangeConfig, +} + +/// Internal HTTP client that handles requests with authentication and retries +#[derive(Clone)] +pub struct HttpClient { + client: Client, + executor: Option>, + config: ClientConfig, + /// Optional OAuth configuration for automatic token management + oauth_config: Option, +} + +impl HttpClient { + /// Creates a new HttpClient, enabling OAuth automatically when the configuration + /// provides an OAuth token endpoint together with client credentials. + pub fn new(config: ClientConfig) -> Result { + let oauth_config = match ( + config.oauth_token_endpoint.as_ref(), + config.client_id.as_ref(), + config.client_secret.as_ref(), + ) { + (Some(token_endpoint), Some(client_id), Some(client_secret)) => Some(OAuthConfig { + token_provider: Arc::new(OAuthTokenProvider::new( + client_id.clone(), + client_secret.clone(), + )), + token_endpoint: token_endpoint.clone(), + exchange: config.oauth_token_exchange.clone().unwrap_or_default(), + }), + _ => None, + }; + Self::new_with_oauth(config, oauth_config) + } + + /// Creates a new HttpClient with optional OAuth support. + /// + /// When `oauth_config` is provided, the client will automatically fetch and refresh + /// OAuth tokens before making requests. + pub fn new_with_oauth( + config: ClientConfig, + oauth_config: Option, + ) -> Result { + let client = Client::builder() + .timeout(config.timeout) + .user_agent(&config.user_agent) + .build() + .map_err(ApiError::Network)?; + + Ok(Self { + client, + executor: None, + config, + oauth_config, + }) + } + + /// Creates an HttpClient with an injected request executor. + /// + /// When using an injected executor, the client delegates HTTP execution + /// entirely to the executor. Auth headers, custom headers, and retry + /// logic are NOT applied by this client — the executor's transport + /// stack is expected to handle them. This prevents double-retry and + /// double-auth when the SDK is embedded inside a CLI. + #[doc(hidden)] + pub fn with_executor(executor: Arc, config: ClientConfig) -> Self { + let client = Client::new(); + Self { + client, + executor: Some(executor), + config, + oauth_config: None, + } + } + + /// Returns the configured base URL. + pub fn base_url(&self) -> &str { + &self.config.base_url + } + + /// Returns a reference to the client configuration. + pub fn config(&self) -> &ClientConfig { + &self.config + } + + /// Execute a request and return the parsed body along with HTTP status code and headers. + /// + /// Unlike `execute_request`, this method preserves the HTTP metadata from the response, + /// which is useful for paginated endpoints where callers need access to status codes + /// and headers alongside the deserialized body. + pub async fn execute_request_raw( + &self, + method: Method, + path: &str, + body: Option, + query_params: Option>, + options: Option, + ) -> Result, ApiError> + where + T: DeserializeOwned, + { + let url = join_url(&self.config.base_url, path); + let mut request = self.client.request(method, &url); + + if let Some(params) = query_params { + request = request.query(¶ms); + } + + if let Some(opts) = &options { + if !opts.additional_query_params.is_empty() { + request = request.query(&opts.additional_query_params); + } + } + + if let Some(body) = body { + request = request.json(&body); + } + + let req = request.build().map_err(|e| ApiError::Network(e))?; + + let response = self.send_request(req, &options).await?; + self.parse_response_raw(response).await + } + + /// Execute a request with the given method, path, and options + pub async fn execute_request( + &self, + method: Method, + path: &str, + body: Option, + query_params: Option>, + options: Option, + ) -> Result + where + T: DeserializeOwned, + { + let url = join_url(&self.config.base_url, path); + let mut request = self.client.request(method, &url); + + if let Some(params) = query_params { + request = request.query(¶ms); + } + + if let Some(opts) = &options { + if !opts.additional_query_params.is_empty() { + request = request.query(&opts.additional_query_params); + } + } + + if let Some(body) = body { + request = request.json(&body); + } + + let req = request.build().map_err(|e| ApiError::Network(e))?; + + let response = self.send_request(req, &options).await?; + self.parse_response(response).await + } + + /// Execute a request with an explicit base URL override. + /// + /// Used for multi-URL environments where different endpoints + /// resolve to different base URLs. + pub async fn execute_request_with_base_url( + &self, + base_url: &str, + method: Method, + path: &str, + body: Option, + query_params: Option>, + options: Option, + ) -> Result + where + T: DeserializeOwned, + { + let url = join_url(base_url, path); + let mut request = self.client.request(method, &url); + + if let Some(params) = query_params { + request = request.query(¶ms); + } + + if let Some(opts) = &options { + if !opts.additional_query_params.is_empty() { + request = request.query(&opts.additional_query_params); + } + } + + if let Some(body) = body { + request = request.json(&body); + } + + let req = request.build().map_err(|e| ApiError::Network(e))?; + + let response = self.send_request(req, &options).await?; + self.parse_response(response).await + } + + /// Applies auth/headers and executes the request, choosing between + /// the injected executor path (no SDK-level auth/headers/retries) + /// and the default path (full SDK behavior). + async fn send_request( + &self, + req: Request, + options: &Option, + ) -> Result { + if let Some(executor) = &self.executor { + executor.execute(req).await.map_err(ApiError::Executor) + } else { + let mut req = req; + self.apply_auth_headers(&mut req, options).await?; + self.apply_custom_headers(&mut req, options)?; + self.execute_with_retries(req, options).await + } + } + + async fn apply_auth_headers( + &self, + request: &mut Request, + options: &Option, + ) -> Result<(), ApiError> { + let headers = request.headers_mut(); + + // Apply API key (request options override config) + let api_key = options + .as_ref() + .and_then(|opts| opts.api_key.as_ref()) + .or(self.config.api_key.as_ref()); + + if let Some(key) = api_key { + let header_value = key.to_string(); + headers.insert( + "api_key", + header_value.parse().map_err(|_| ApiError::InvalidHeader)?, + ); + } + + // Apply bearer token - priority: request options > OAuth > config + let token = if let Some(opts) = options.as_ref() { + if opts.token.is_some() { + opts.token.clone() + } else { + None + } + } else { + None + }; + + let token = match token { + Some(t) => Some(t), + None => { + // Try OAuth token provider if configured + if let Some(oauth_config) = &self.oauth_config { + Some(self.get_oauth_token(oauth_config).await?) + } else { + // Fall back to static token from config + self.config.token.clone() + } + } + }; + + if let Some(token) = token { + let auth_value = format!("Bearer {}", token); + headers.insert( + "Authorization", + auth_value.parse().map_err(|_| ApiError::InvalidHeader)?, + ); + } + + Ok(()) + } + + /// Fetches an OAuth token, using the cached token if valid or fetching a new one. + async fn get_oauth_token(&self, oauth_config: &OAuthConfig) -> Result { + let token_provider = &oauth_config.token_provider; + let token_endpoint = &oauth_config.token_endpoint; + let client_id = token_provider.client_id().to_string(); + let client_secret = token_provider.client_secret().to_string(); + let base_url = self.config.base_url.clone(); + + let exchange = &oauth_config.exchange; + + // Use the async get_or_fetch method with a closure that fetches the token + token_provider + .get_or_fetch_async(|| async { + self.fetch_oauth_token( + &base_url, + token_endpoint, + &client_id, + &client_secret, + exchange, + ) + .await + }) + .await + } + + /// Makes an HTTP request to the OAuth token endpoint to fetch a new token. + /// + /// The request body and response are keyed by the property names configured on the + /// API's OAuth scheme (via `exchange`), so non-standard token contracts (e.g. camelCase + /// field names or an absent `grant_type`) are honored instead of a hardcoded shape. + /// + /// Config-level custom headers are applied to the token request, since gateways often + /// require them on the token endpoint too. Request-level headers are deliberately not + /// applied: the token is cached and shared across requests, so it must not depend on the + /// options of whichever request happens to trigger the fetch. Auth headers are also not + /// applied, as this request is what produces the credential they would carry. + /// + /// The body is encoded to match the token endpoint's declared content type: form-encoded + /// (`application/x-www-form-urlencoded`, per RFC 6749 §4.4.2) when `exchange.form_encoded` + /// is set, otherwise JSON. + async fn fetch_oauth_token( + &self, + base_url: &str, + token_endpoint: &str, + client_id: &str, + client_secret: &str, + exchange: &OAuthTokenExchangeConfig, + ) -> Result<(String, u64), ApiError> { + let url = join_url(base_url, token_endpoint); + + // Collect the token request properties (keyed by the configured names) as ordered + // key/value pairs, then encode them as form or JSON depending on the endpoint. + let mut params: Vec<(String, String)> = Vec::new(); + params.push((exchange.client_id_property.clone(), client_id.to_string())); + params.push(( + exchange.client_secret_property.clone(), + client_secret.to_string(), + )); + for (name, value) in &exchange.extra_request_properties { + params.push((name.clone(), value.clone())); + } + + let builder = self.client.request(Method::POST, &url); + let builder = if exchange.form_encoded { + builder.form(¶ms) + } else { + let body = params + .into_iter() + .map(|(name, value)| (name, serde_json::Value::String(value))) + .collect::>(); + builder.json(&serde_json::Value::Object(body)) + }; + let mut request = builder.build().map_err(ApiError::Network)?; + self.apply_custom_headers(&mut request, &None)?; + + let response = self + .client + .execute(request) + .await + .map_err(ApiError::Network)?; + + let status_code = response.status().as_u16(); + if !response.status().is_success() { + let body = response.text().await.ok(); + return Err(ApiError::from_response(status_code, body.as_deref())); + } + + // Parse the token response using the configured property names. + let token_response: serde_json::Value = response.json().await.map_err(ApiError::Network)?; + + let access_token = token_response + .get(&exchange.access_token_property) + .and_then(|value| value.as_str()) + .ok_or_else(|| ApiError::Http { + status: status_code, + message: "OAuth token response is missing the access token".to_string(), + })? + .to_string(); + + let expires_in = token_response + .get(&exchange.expires_in_property) + .and_then(|value| value.as_i64()) + .unwrap_or(3600) as u64; + + Ok((access_token, expires_in)) + } + + fn apply_custom_headers( + &self, + request: &mut Request, + options: &Option, + ) -> Result<(), ApiError> { + let headers = request.headers_mut(); + + // Apply config-level custom headers + for (key, value) in &self.config.custom_headers { + headers.insert( + HeaderName::from_str(key).map_err(|_| ApiError::InvalidHeader)?, + HeaderValue::from_str(value).map_err(|_| ApiError::InvalidHeader)?, + ); + } + + // Apply request-level custom headers (override config) + if let Some(options) = options { + for (key, value) in &options.additional_headers { + headers.insert( + HeaderName::from_str(key).map_err(|_| ApiError::InvalidHeader)?, + HeaderValue::from_str(value).map_err(|_| ApiError::InvalidHeader)?, + ); + } + } + + Ok(()) + } + + async fn execute_with_retries( + &self, + request: Request, + options: &Option, + ) -> Result { + let max_retries = options + .as_ref() + .and_then(|opts| opts.max_retries) + .unwrap_or(self.config.max_retries); + + let mut last_error = None; + + for attempt in 0..=max_retries { + let cloned_request = request.try_clone().ok_or(ApiError::RequestClone)?; + + match self.client.execute(cloned_request).await { + Ok(response) if response.status().is_success() => return Ok(response), + Ok(response) + if attempt < max_retries + && Self::is_retryable_status(response.status().as_u16()) => + { + // Exponential backoff for retryable HTTP status codes + let delay = std::time::Duration::from_millis(100 * 2_u64.pow(attempt)); + tokio::time::sleep(delay).await; + } + Ok(response) => { + let status_code = response.status().as_u16(); + let body = response.text().await.ok(); + return Err(ApiError::from_response(status_code, body.as_deref())); + } + Err(e) if attempt < max_retries => { + last_error = Some(e); + // Exponential backoff + let delay = std::time::Duration::from_millis(100 * 2_u64.pow(attempt)); + tokio::time::sleep(delay).await; + } + Err(e) => return Err(ApiError::Network(e)), + } + } + + Err(ApiError::Network(last_error.unwrap())) + } + + fn is_retryable_status(status_code: u16) -> bool { + [408, 429].contains(&status_code) || status_code >= 500 + } + + async fn parse_response(&self, response: Response) -> Result + where + T: DeserializeOwned, + { + let status = response.status().as_u16(); + let text = response.text().await.map_err(ApiError::Network)?; + + // The status is authoritative, and it must be consulted *before* the + // body is deserialized. An error payload that happens to fit `T` — + // all-optional fields, a bare `Value`, an empty collection — would + // otherwise be returned as a successful call, so a 401 surfaces as + // "no results" and the caller has no way to tell: `T` carries no + // status. The body is preserved as the error message so the server's + // own detail reaches the caller. + if status >= 400 { + return Err(ApiError::Http { + status, + message: text, + }); + } + + if text.is_empty() { + return serde_json::from_value(serde_json::Value::Null).map_err(|_| ApiError::Http { + status, + message: String::new(), + }); + } + + serde_json::from_str(&text).map_err(ApiError::Serialization) + } + + async fn parse_response_raw(&self, response: Response) -> Result, ApiError> + where + T: DeserializeOwned, + { + let status_code = response.status().as_u16(); + let headers = response.headers().clone(); + let text = response.text().await.map_err(ApiError::Network)?; + + // Same contract as `parse_response`: a non-2xx is an error even though + // `RawResponse` could carry the status, because the success type `T` + // would still have to absorb an error payload. Callers that need the + // raw status of a failure read it off `ApiError::Http`. + if status_code >= 400 { + return Err(ApiError::Http { + status: status_code, + message: text, + }); + } + + if text.is_empty() { + return serde_json::from_value(serde_json::Value::Null) + .map(|body| RawResponse { + body, + status_code, + headers, + }) + .map_err(|_| ApiError::Http { + status: status_code, + message: String::new(), + }); + } + + let body: T = serde_json::from_str(&text).map_err(ApiError::Serialization)?; + Ok(RawResponse { + body, + status_code, + headers, + }) + } + + /// Execute a request and return a streaming response (for large file downloads) + /// + /// This method returns a `ByteStream` that can be used to download large files + /// efficiently without loading the entire content into memory. The stream can be + /// consumed chunk by chunk, written directly to disk, or collected into bytes. + /// + /// # Examples + /// + /// **Option 1: Collect all bytes into memory** + /// ```no_run + /// let stream = client.execute_stream_request( + /// Method::GET, + /// "/file", + /// None, + /// None, + /// None, + /// ).await?; + /// + /// let bytes = stream.collect().await?; + /// ``` + /// + /// **Option 2: Process chunks with try_next()** + /// ```no_run + /// let mut stream = client.execute_stream_request( + /// Method::GET, + /// "/large-file", + /// None, + /// None, + /// None, + /// ).await?; + /// + /// while let Some(chunk) = stream.try_next().await? { + /// process_chunk(&chunk); + /// } + /// ``` + /// + /// **Option 3: Stream with futures::Stream trait** + /// ```no_run + /// use futures::StreamExt; + /// + /// let stream = client.execute_stream_request( + /// Method::GET, + /// "/large-file", + /// None, + /// None, + /// None, + /// ).await?; + /// + /// let mut file = tokio::fs::File::create("output.mp4").await?; + /// let mut stream = std::pin::pin!(stream); + /// while let Some(chunk) = stream.next().await { + /// let chunk = chunk?; + /// tokio::io::AsyncWriteExt::write_all(&mut file, &chunk).await?; + /// } + /// ``` + pub async fn execute_stream_request( + &self, + method: Method, + path: &str, + body: Option, + query_params: Option>, + options: Option, + ) -> Result { + let url = join_url(&self.config.base_url, path); + let mut request = self.client.request(method, &url); + + // Apply query parameters if provided + if let Some(params) = query_params { + request = request.query(¶ms); + } + + // Apply additional query parameters from options + if let Some(opts) = &options { + if !opts.additional_query_params.is_empty() { + request = request.query(&opts.additional_query_params); + } + } + + // Apply body if provided + if let Some(body) = body { + request = request.json(&body); + } + + // Build the request + let req = request.build().map_err(|e| ApiError::Network(e))?; + + let response = self.send_request(req, &options).await?; + + // Return streaming response + Ok(ByteStream::new(response)) + } + + /// Execute a streaming request with an explicit base URL override. + pub async fn execute_stream_request_with_base_url( + &self, + base_url: &str, + method: Method, + path: &str, + body: Option, + query_params: Option>, + options: Option, + ) -> Result { + let url = join_url(base_url, path); + let mut request = self.client.request(method, &url); + + if let Some(params) = query_params { + request = request.query(¶ms); + } + + if let Some(opts) = &options { + if !opts.additional_query_params.is_empty() { + request = request.query(&opts.additional_query_params); + } + } + + if let Some(body) = body { + request = request.json(&body); + } + + let req = request.build().map_err(|e| ApiError::Network(e))?; + + let response = self.send_request(req, &options).await?; + + Ok(ByteStream::new(response)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_retryable_status() { + // Retryable 4xx + assert!(HttpClient::is_retryable_status(408)); + assert!(HttpClient::is_retryable_status(429)); + + // Retryable 5xx (>= 500) + assert!(HttpClient::is_retryable_status(500)); + assert!(HttpClient::is_retryable_status(501)); + assert!(HttpClient::is_retryable_status(502)); + assert!(HttpClient::is_retryable_status(503)); + assert!(HttpClient::is_retryable_status(504)); + assert!(HttpClient::is_retryable_status(599)); + + // Success and other 4xx codes are NOT retryable + assert!(!HttpClient::is_retryable_status(200)); + assert!(!HttpClient::is_retryable_status(400)); + assert!(!HttpClient::is_retryable_status(401)); + assert!(!HttpClient::is_retryable_status(404)); + } + + /// A payload shaped so that it deserializes cleanly from *any* JSON object, + /// including an error body. This is what makes the status check load-bearing: + /// with an all-optional success type, deserialization alone cannot tell a + /// result from an error. + #[derive(Debug, Default, serde::Deserialize)] + struct PermissivePayload { + #[serde(default)] + agents: Vec, + } + + /// Serve one raw HTTP response on an ephemeral port and return its URL. + /// Avoids a dev-dependency on a mock-server crate: the point is to obtain a + /// genuine `reqwest::Response` so the parsers are exercised on the real path + /// rather than through a hand-built stand-in. + async fn serve_once(status_line: &'static str, body: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let url = format!("http://{}/", listener.local_addr().expect("local addr")); + tokio::spawn(async move { + if let Ok((mut socket, _)) = listener.accept().await { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut discard = [0u8; 1024]; + let _ = socket.read(&mut discard).await; + let response = format!( + "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + let _ = socket.flush().await; + } + }); + url + } + + fn test_client() -> HttpClient { + HttpClient::new(ClientConfig::default()).expect("construct client") + } + + #[tokio::test] + async fn test_parse_response_errors_on_non_2xx_with_a_deserializable_body() { + // Regression: the status check used to live inside the `text.is_empty()` + // branch, so a non-2xx carrying a body was deserialized and returned + // `Ok`. With a permissive success type the call then looked like an + // empty-but-successful result — a 401 reported as "no agents found", + // exit code 0, which a script reads as "nothing to do". + let url = serve_once("401 Unauthorized", r#"{"detail":"invalid api key"}"#).await; + let response = reqwest::get(&url).await.expect("request completes"); + + let result: Result = + test_client().parse_response(response).await; + + match result { + Err(ApiError::Http { status, message }) => { + assert_eq!(status, 401); + assert!( + message.contains("invalid api key"), + "the server's error detail must reach the caller, got: {message}" + ); + } + Err(other) => panic!("expected ApiError::Http, got {other:?}"), + Ok(_) => panic!("a 401 with a body must not be reported as success"), + } + } + + #[tokio::test] + async fn test_parse_response_raw_errors_on_non_2xx_with_a_deserializable_body() { + let url = serve_once("500 Internal Server Error", r#"{"agents":[]}"#).await; + let response = reqwest::get(&url).await.expect("request completes"); + + let result: Result, ApiError> = + test_client().parse_response_raw(response).await; + + match result { + Err(ApiError::Http { status, .. }) => assert_eq!(status, 500), + Err(other) => panic!("expected ApiError::Http, got {other:?}"), + Ok(_) => panic!("a 500 with a body must not be reported as success"), + } + } + + #[tokio::test] + async fn test_parse_response_still_succeeds_on_2xx() { + // The status gate must not swallow the happy path. + let url = serve_once("200 OK", r#"{"agents":["one","two"]}"#).await; + let response = reqwest::get(&url).await.expect("request completes"); + + let parsed: PermissivePayload = test_client() + .parse_response(response) + .await + .expect("a 200 must deserialize"); + + assert_eq!(parsed.agents, vec!["one", "two"]); + } + + #[tokio::test] + async fn test_parse_response_raw_still_exposes_status_on_2xx() { + let url = serve_once("201 Created", r#"{"agents":["one"]}"#).await; + let response = reqwest::get(&url).await.expect("request completes"); + + let raw: RawResponse = test_client() + .parse_response_raw(response) + .await + .expect("a 201 must deserialize"); + + assert_eq!(raw.status_code, 201); + assert_eq!(raw.body.agents, vec!["one"]); + } + + /// Accepts a single connection, returns the raw request text and replies with a token. + async fn serve_one_token_request(listener: tokio::net::TcpListener) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut raw = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = socket.read(&mut buffer).await.expect("read"); + raw.extend_from_slice(&buffer[..read]); + if read == 0 || String::from_utf8_lossy(&raw).contains("\r\n\r\n") { + break; + } + } + + let body = r#"{"access_token":"token-from-server","expires_in":3600}"#; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.expect("write"); + socket.flush().await.expect("flush"); + + String::from_utf8_lossy(&raw).to_string() + } + + #[tokio::test] + async fn test_oauth_token_request_sends_custom_headers() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let base_url = format!("http://{}", listener.local_addr().expect("addr")); + let server = tokio::spawn(serve_one_token_request(listener)); + + let mut config = ClientConfig::default(); + config.base_url = base_url; + config + .custom_headers + .insert("X-Gateway-Token".to_string(), "sunflower".to_string()); + let client = HttpClient::new(config).expect("client"); + + let (access_token, _) = client + .fetch_oauth_token( + &client.config.base_url.clone(), + "/token", + "client-id", + "client-secret", + &OAuthTokenExchangeConfig::default(), + ) + .await + .expect("token"); + + let raw_request = server.await.expect("server"); + assert_eq!(access_token, "token-from-server"); + assert!( + raw_request.contains("x-gateway-token: sunflower"), + "token request is missing the client's custom headers: {raw_request}" + ); + } + + #[tokio::test] + async fn test_oauth_token_request_form_encodes_when_configured() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let base_url = format!("http://{}", listener.local_addr().expect("addr")); + let server = tokio::spawn(serve_one_token_request(listener)); + + let mut config = ClientConfig::default(); + config.base_url = base_url; + let client = HttpClient::new(config).expect("client"); + + let exchange = OAuthTokenExchangeConfig { + form_encoded: true, + ..OAuthTokenExchangeConfig::default() + }; + let (access_token, _) = client + .fetch_oauth_token( + &client.config.base_url.clone(), + "/token", + "client-id", + "client-secret", + &exchange, + ) + .await + .expect("token"); + + let raw_request = server.await.expect("server"); + assert_eq!(access_token, "token-from-server"); + assert!( + raw_request.contains("content-type: application/x-www-form-urlencoded"), + "token request should be form-encoded when the endpoint declares it: {raw_request}" + ); + assert!( + !raw_request.contains("content-type: application/json"), + "token request should not send a JSON content type when form-encoded: {raw_request}" + ); + } +} diff --git a/agentmail-sdk/src/core/mod.rs b/agentmail-sdk/src/core/mod.rs new file mode 100644 index 0000000..dcb26eb --- /dev/null +++ b/agentmail-sdk/src/core/mod.rs @@ -0,0 +1,19 @@ +//! Core client infrastructure + +pub mod flexible_datetime; +mod http_client; +pub mod number_serializers; +mod oauth_token_provider; +pub mod pagination; +mod query_parameter_builder; +mod request_options; +mod utils; + +pub use http_client::{ + ByteStream, HttpClient, OAuthConfig, OAuthTokenExchangeConfig, RawResponse, RequestExecutor, +}; +pub use oauth_token_provider::OAuthTokenProvider; +pub use pagination::{AsyncPaginator, PaginationResult, SyncPaginator}; +pub use query_parameter_builder::{parse_structured_query, QueryBuilder, QueryBuilderError}; +pub use request_options::RequestOptions; +pub use utils::join_url; diff --git a/agentmail-sdk/src/core/number_serializers.rs b/agentmail-sdk/src/core/number_serializers.rs new file mode 100644 index 0000000..0e16157 --- /dev/null +++ b/agentmail-sdk/src/core/number_serializers.rs @@ -0,0 +1,177 @@ +//! Number serialization helpers +//! +//! This module provides serde helpers for serializing f64 values +//! that strips trailing `.0` from whole numbers (e.g., 24000.0 → 24000). +//! Some APIs reject the decimal representation for integer-valued numbers. +//! +//! Usage: +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyStruct { +//! #[serde(with = "crate::core::number_serializers")] +//! sample_rate: f64, +//! } +//! ``` + +use serde::{self, Deserialize, Deserializer, Serialize, Serializer}; + +/// Serialize an f64, omitting the decimal point for whole numbers. +/// e.g., 24000.0 → 24000, 3.14 → 3.14 +pub fn serialize(value: &f64, serializer: S) -> Result +where + S: Serializer, +{ + if value.fract() == 0.0 + && value.is_finite() + && *value >= (i64::MIN as f64) + && *value <= (i64::MAX as f64) + { + // Serialize as integer to avoid trailing .0 + (*value as i64).serialize(serializer) + } else { + value.serialize(serializer) + } +} + +/// Deserialize an f64 (accepts both integer and float JSON values) +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + f64::deserialize(deserializer) +} + +/// Module for optional f64 fields +pub mod option { + use super::*; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(v) => { + if v.fract() == 0.0 + && v.is_finite() + && *v >= (i64::MIN as f64) + && *v <= (i64::MAX as f64) + { + serializer.serialize_some(&(*v as i64)) + } else { + serializer.serialize_some(v) + } + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + #[serde(with = "super")] + value: f64, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStructOptional { + #[serde(default)] + #[serde(with = "super::option")] + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + } + + #[test] + fn test_whole_number_no_decimal() { + let test = TestStruct { value: 24000.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":24000}"#); + } + + #[test] + fn test_fractional_keeps_decimal() { + let test = TestStruct { value: 3.14 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":3.14}"#); + } + + #[test] + fn test_zero() { + let test = TestStruct { value: 0.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":0}"#); + } + + #[test] + fn test_negative_whole() { + let test = TestStruct { value: -100.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":-100}"#); + } + + #[test] + fn test_deserialize_from_integer() { + let json = r#"{"value":24000}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 24000.0); + } + + #[test] + fn test_deserialize_from_float() { + let json = r#"{"value":3.14}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 3.14); + } + + #[test] + fn test_roundtrip() { + let original = TestStruct { value: 44100.0 }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: TestStruct = serde_json::from_str(&json).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_optional_some_whole() { + let test = TestStructOptional { + value: Some(16000.0), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":16000}"#); + } + + #[test] + fn test_optional_none() { + let test = TestStructOptional { value: None }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{}"#); + } + + #[test] + fn test_optional_deserialize_missing() { + let json = r#"{}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, None); + } + + #[test] + fn test_large_whole_number_outside_i64_range() { + let test = TestStruct { value: 1e20 }; + let json = serde_json::to_string(&test).unwrap(); + // Should fall back to f64 serialization, not saturate to i64::MAX + assert_eq!(json, r#"{"value":1e+20}"#); + } +} diff --git a/agentmail-sdk/src/core/oauth_token_provider.rs b/agentmail-sdk/src/core/oauth_token_provider.rs new file mode 100644 index 0000000..09222be --- /dev/null +++ b/agentmail-sdk/src/core/oauth_token_provider.rs @@ -0,0 +1,363 @@ +use std::future::Future; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex as AsyncMutex; + +/// Buffer time in seconds subtracted from token expiration to ensure +/// we refresh the token before it actually expires. +const EXPIRATION_BUFFER_SECONDS: u64 = 120; // 2 minutes + +/// Default expiry time in seconds used when the OAuth response doesn't include an expires_in value. +const DEFAULT_EXPIRY_SECONDS: u64 = 3600; // 1 hour fallback + +/// Manages OAuth access tokens, including caching and automatic refresh. +/// +/// This provider implements thread-safe token management with automatic expiration +/// handling. It uses a double-checked locking pattern to minimize lock contention +/// while ensuring only one thread fetches a new token at a time. +/// +/// # Example +/// +/// ```rust,ignore +/// use crate::OAuthTokenProvider; +/// +/// let provider = OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string()); +/// +/// // Get or fetch a token (sync) +/// let token = provider.get_or_fetch(|| { +/// // Your token fetching logic here +/// // Returns (access_token, expires_in_seconds) +/// Ok(("token".to_string(), Some(3600))) +/// })?; +/// +/// // Get or fetch a token (async) +/// let token = provider.get_or_fetch_async(|| async { +/// // Your async token fetching logic here +/// Ok(("token".to_string(), Some(3600))) +/// }).await?; +/// ``` +pub struct OAuthTokenProvider { + client_id: String, + client_secret: String, + inner: Mutex, + /// Separate mutex to ensure only one thread fetches a new token at a time (sync) + fetch_lock: Mutex<()>, + /// Async mutex for async token fetching + async_fetch_lock: AsyncMutex<()>, +} + +struct OAuthTokenProviderInner { + access_token: Option, + expires_at: Option, +} + +impl OAuthTokenProvider { + /// Creates a new OAuthTokenProvider with the given credentials. + pub fn new(client_id: String, client_secret: String) -> Self { + Self { + client_id, + client_secret, + inner: Mutex::new(OAuthTokenProviderInner { + access_token: None, + expires_at: None, + }), + fetch_lock: Mutex::new(()), + async_fetch_lock: AsyncMutex::new(()), + } + } + + /// Returns the client ID. + pub fn client_id(&self) -> &str { + &self.client_id + } + + /// Returns the client secret. + pub fn client_secret(&self) -> &str { + &self.client_secret + } + + /// Sets the cached access token and its expiration time. + /// + /// The `expires_in` parameter is the number of seconds until the token expires. + /// A buffer is applied to refresh before actual expiration. + pub fn set_token(&self, access_token: String, expires_in: u64) { + let mut inner = self.inner.lock().unwrap(); + inner.access_token = Some(access_token); + + if expires_in > 0 { + // Apply buffer to refresh before actual expiration + let effective_expires_in = expires_in.saturating_sub(EXPIRATION_BUFFER_SECONDS); + inner.expires_at = Some(Instant::now() + Duration::from_secs(effective_expires_in)); + } else { + // No expiration info, token won't auto-refresh based on time + inner.expires_at = None; + } + } + + /// Returns the cached access token if it's still valid. + /// + /// Returns `None` if the token is expired or not set. + pub fn get_token(&self) -> Option { + let inner = self.inner.lock().unwrap(); + + if let Some(ref token) = inner.access_token { + // Check if token is still valid + if let Some(expires_at) = inner.expires_at { + if Instant::now() < expires_at { + return Some(token.clone()); + } + } else { + // No expiration set, token is always valid + return Some(token.clone()); + } + } + + None + } + + /// Returns a valid token, fetching a new one if necessary (synchronous version). + /// + /// The `fetch_func` is called at most once even if multiple threads call `get_or_fetch` + /// concurrently when the token is expired. It should return `(access_token, expires_in_seconds)`. + /// + /// # Arguments + /// + /// * `fetch_func` - A function that fetches a new token. Returns `Result<(String, u64), E>` + /// where the tuple contains (access_token, expires_in_seconds). + /// + /// # Example + /// + /// ```rust,ignore + /// let token = provider.get_or_fetch(|| { + /// // Call your OAuth endpoint here (sync) + /// let response = auth_client.get_token(&provider.client_id(), &provider.client_secret())?; + /// Ok((response.access_token, response.expires_in.unwrap_or(3600))) + /// })?; + /// ``` + pub fn get_or_fetch(&self, fetch_func: F) -> Result + where + F: FnOnce() -> Result<(String, u64), E>, + { + // Fast path: check if we have a valid token + if let Some(token) = self.get_token() { + return Ok(token); + } + + // Slow path: acquire fetch lock to ensure only one thread fetches + let _fetch_guard = self.fetch_lock.lock().unwrap(); + + // Double-check after acquiring lock (another thread may have fetched) + if let Some(token) = self.get_token() { + return Ok(token); + } + + // Fetch new token + let (access_token, expires_in) = fetch_func()?; + + // Use default expiry if not provided + let effective_expires_in = if expires_in > 0 { + expires_in + } else { + DEFAULT_EXPIRY_SECONDS + }; + + self.set_token(access_token.clone(), effective_expires_in); + Ok(access_token) + } + + /// Returns a valid token, fetching a new one if necessary (async version). + /// + /// This is the async version of `get_or_fetch` for use with async token fetching. + /// The `fetch_func` is called at most once even if multiple tasks call `get_or_fetch_async` + /// concurrently when the token is expired. + /// + /// # Arguments + /// + /// * `fetch_func` - An async function that fetches a new token. Returns `Result<(String, u64), E>` + /// where the tuple contains (access_token, expires_in_seconds). + /// + /// # Example + /// + /// ```rust,ignore + /// let token = provider.get_or_fetch_async(|| async { + /// // Call your OAuth endpoint here (async) + /// let response = auth_client.get_token(&provider.client_id(), &provider.client_secret()).await?; + /// Ok((response.access_token, response.expires_in.unwrap_or(3600))) + /// }).await?; + /// ``` + pub async fn get_or_fetch_async(&self, fetch_func: F) -> Result + where + F: FnOnce() -> Fut, + Fut: Future>, + { + // Fast path: check if we have a valid token + if let Some(token) = self.get_token() { + return Ok(token); + } + + // Slow path: acquire async fetch lock to ensure only one task fetches + let _fetch_guard = self.async_fetch_lock.lock().await; + + // Double-check after acquiring lock (another task may have fetched) + if let Some(token) = self.get_token() { + return Ok(token); + } + + // Fetch new token + let (access_token, expires_in) = fetch_func().await?; + + // Use default expiry if not provided + let effective_expires_in = if expires_in > 0 { + expires_in + } else { + DEFAULT_EXPIRY_SECONDS + }; + + self.set_token(access_token.clone(), effective_expires_in); + Ok(access_token) + } + + /// Returns `true` if the token needs to be refreshed. + /// + /// This is useful for proactively refreshing tokens before they expire. + pub fn needs_refresh(&self) -> bool { + let inner = self.inner.lock().unwrap(); + + if inner.access_token.is_none() { + return true; + } + + if let Some(expires_at) = inner.expires_at { + if Instant::now() >= expires_at { + return true; + } + } + + false + } + + /// Clears the cached token. + /// + /// This can be used to force a token refresh on the next request. + pub fn reset(&self) { + let mut inner = self.inner.lock().unwrap(); + inner.access_token = None; + inner.expires_at = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + use std::thread; + + #[test] + fn test_new_provider() { + let provider = + OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string()); + assert_eq!(provider.client_id(), "client_id"); + assert_eq!(provider.client_secret(), "client_secret"); + assert!(provider.get_token().is_none()); + assert!(provider.needs_refresh()); + } + + #[test] + fn test_set_and_get_token() { + let provider = + OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string()); + + provider.set_token("test_token".to_string(), 3600); + + let token = provider.get_token(); + assert!(token.is_some()); + assert_eq!(token.unwrap(), "test_token"); + assert!(!provider.needs_refresh()); + } + + #[test] + fn test_expired_token() { + let provider = + OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string()); + + // Set token with 0 expiry (will be expired immediately due to buffer) + provider.set_token("test_token".to_string(), 1); + + // Token should be expired (1 second - 120 second buffer = expired) + assert!(provider.get_token().is_none()); + assert!(provider.needs_refresh()); + } + + #[test] + fn test_get_or_fetch() { + let provider = + OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string()); + + let result: Result = + provider.get_or_fetch(|| Ok(("fetched_token".to_string(), 3600))); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "fetched_token"); + + // Second call should return cached token + let result2: Result = provider.get_or_fetch(|| { + panic!("Should not be called - token is cached"); + }); + + assert!(result2.is_ok()); + assert_eq!(result2.unwrap(), "fetched_token"); + } + + #[test] + fn test_reset() { + let provider = + OAuthTokenProvider::new("client_id".to_string(), "client_secret".to_string()); + + provider.set_token("test_token".to_string(), 3600); + assert!(provider.get_token().is_some()); + + provider.reset(); + assert!(provider.get_token().is_none()); + assert!(provider.needs_refresh()); + } + + #[test] + fn test_concurrent_access() { + let provider = Arc::new(OAuthTokenProvider::new( + "client_id".to_string(), + "client_secret".to_string(), + )); + let fetch_count = Arc::new(AtomicUsize::new(0)); + + let mut handles = vec![]; + + for _ in 0..10 { + let provider_clone = Arc::clone(&provider); + let fetch_count_clone = Arc::clone(&fetch_count); + + let handle = thread::spawn(move || { + let result: Result = provider_clone.get_or_fetch(|| { + fetch_count_clone.fetch_add(1, Ordering::SeqCst); + // Simulate some work + thread::sleep(Duration::from_millis(10)); + Ok(("concurrent_token".to_string(), 3600)) + }); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "concurrent_token"); + }); + + handles.push(handle); + } + + for handle in handles { + handle.join().unwrap(); + } + + // Due to double-checked locking, fetch should only be called once + // (or at most a few times if threads race before the first fetch completes) + let count = fetch_count.load(Ordering::SeqCst); + assert!(count >= 1 && count <= 3, "Fetch was called {} times", count); + } +} diff --git a/agentmail-sdk/src/core/pagination.rs b/agentmail-sdk/src/core/pagination.rs new file mode 100644 index 0000000..be6e2cc --- /dev/null +++ b/agentmail-sdk/src/core/pagination.rs @@ -0,0 +1,681 @@ +use std::collections::VecDeque; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use futures::Stream; +use reqwest::header::HeaderMap; +use serde_json::Value; + +use crate::{ApiError, HttpClient}; + +/// Result of a pagination request, including HTTP metadata from the response. +#[derive(Debug)] +pub struct PaginationResult { + pub items: Vec, + pub next_cursor: Option, + pub has_next_page: bool, + /// The full parsed response body as a JSON value. + pub response: Option, + /// The HTTP status code of the response. + pub status_code: u16, + /// The HTTP response headers. + pub headers: HeaderMap, +} + +/// Async paginator that implements Stream for iterating over paginated results +pub struct AsyncPaginator { + http_client: Arc, + page_loader: Box< + dyn Fn( + Arc, + Option, + ) + -> Pin, ApiError>> + Send>> + + Send + + Sync, + >, + current_page: VecDeque, + current_cursor: Option, + has_next_page: bool, + loading_next: + Option, ApiError>> + Send>>>, + last_response: Option, + last_status_code: u16, + last_headers: HeaderMap, +} + +impl AsyncPaginator { + pub fn new( + http_client: Arc, + page_loader: F, + initial_cursor: Option, + ) -> Result + where + F: Fn(Arc, Option) -> Fut + Send + Sync + 'static, + Fut: Future, ApiError>> + Send + 'static, + { + Ok(Self { + http_client, + page_loader: Box::new(move |client, cursor| Box::pin(page_loader(client, cursor))), + current_page: VecDeque::new(), + current_cursor: initial_cursor, + has_next_page: true, // Assume true initially, will be updated after first request + loading_next: None, + last_response: None, + last_status_code: 0, + last_headers: HeaderMap::new(), + }) + } + + /// Check if there are more pages available + pub fn has_next_page(&self) -> bool { + !self.current_page.is_empty() || self.has_next_page + } + + /// The full parsed response from the most recent page load. + pub fn response(&self) -> Option<&Value> { + self.last_response.as_ref() + } + + /// The HTTP status code from the most recent page load. + pub fn status_code(&self) -> u16 { + self.last_status_code + } + + /// The HTTP response headers from the most recent page load. + pub fn headers(&self) -> &HeaderMap { + &self.last_headers + } + + /// Load the next page explicitly + pub async fn next_page(&mut self) -> Result, ApiError> { + if !self.has_next_page { + return Ok(Vec::new()); + } + + let result = + (self.page_loader)(self.http_client.clone(), self.current_cursor.clone()).await?; + + self.current_cursor = result.next_cursor; + self.has_next_page = result.has_next_page; + self.last_response = result.response; + self.last_status_code = result.status_code; + self.last_headers = result.headers; + + Ok(result.items) + } +} + +impl Stream for AsyncPaginator +where + T: Unpin, +{ + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // If we have items in the current page, return the next one + if let Some(item) = self.current_page.pop_front() { + return Poll::Ready(Some(Ok(item))); + } + + // If we're already loading the next page, poll that future + if let Some(ref mut loading_future) = self.loading_next { + match loading_future.as_mut().poll(cx) { + Poll::Ready(Ok(result)) => { + self.current_page.extend(result.items); + self.current_cursor = result.next_cursor; + self.has_next_page = result.has_next_page; + self.last_response = result.response; + self.last_status_code = result.status_code; + self.last_headers = result.headers; + self.loading_next = None; + + // Try to get the next item from the newly loaded page + if let Some(item) = self.current_page.pop_front() { + return Poll::Ready(Some(Ok(item))); + } else if !self.has_next_page { + return Poll::Ready(None); + } + // Fall through to start loading next page + } + Poll::Ready(Err(e)) => { + self.loading_next = None; + return Poll::Ready(Some(Err(e))); + } + Poll::Pending => return Poll::Pending, + } + } + + // If we have no more pages to load, we're done + if !self.has_next_page { + return Poll::Ready(None); + } + + // Start loading the next page + let future = (self.page_loader)(self.http_client.clone(), self.current_cursor.clone()); + self.loading_next = Some(future); + + // Poll the future immediately + if let Some(ref mut loading_future) = self.loading_next { + match loading_future.as_mut().poll(cx) { + Poll::Ready(Ok(result)) => { + self.current_page.extend(result.items); + self.current_cursor = result.next_cursor; + self.has_next_page = result.has_next_page; + self.last_response = result.response; + self.last_status_code = result.status_code; + self.last_headers = result.headers; + self.loading_next = None; + + if let Some(item) = self.current_page.pop_front() { + Poll::Ready(Some(Ok(item))) + } else if !self.has_next_page { + Poll::Ready(None) + } else { + // This shouldn't happen, but just in case + cx.waker().wake_by_ref(); + Poll::Pending + } + } + Poll::Ready(Err(e)) => { + self.loading_next = None; + Poll::Ready(Some(Err(e))) + } + Poll::Pending => Poll::Pending, + } + } else { + Poll::Pending + } + } +} + +/// Synchronous paginator for blocking iteration +pub struct SyncPaginator { + http_client: Arc, + page_loader: Box< + dyn Fn(Arc, Option) -> Result, ApiError> + + Send + + Sync, + >, + current_page: VecDeque, + current_cursor: Option, + has_next_page: bool, + last_response: Option, + last_status_code: u16, + last_headers: HeaderMap, +} + +impl SyncPaginator { + pub fn new( + http_client: Arc, + page_loader: F, + initial_cursor: Option, + ) -> Result + where + F: Fn(Arc, Option) -> Result, ApiError> + + Send + + Sync + + 'static, + { + Ok(Self { + http_client, + page_loader: Box::new(page_loader), + current_page: VecDeque::new(), + current_cursor: initial_cursor, + has_next_page: true, // Assume true initially + last_response: None, + last_status_code: 0, + last_headers: HeaderMap::new(), + }) + } + + /// Check if there are more pages available + pub fn has_next_page(&self) -> bool { + !self.current_page.is_empty() || self.has_next_page + } + + /// The full parsed response from the most recent page load. + pub fn response(&self) -> Option<&Value> { + self.last_response.as_ref() + } + + /// The HTTP status code from the most recent page load. + pub fn status_code(&self) -> u16 { + self.last_status_code + } + + /// The HTTP response headers from the most recent page load. + pub fn headers(&self) -> &HeaderMap { + &self.last_headers + } + + /// Load the next page explicitly + pub fn next_page(&mut self) -> Result, ApiError> { + if !self.has_next_page { + return Ok(Vec::new()); + } + + let result = (self.page_loader)(self.http_client.clone(), self.current_cursor.clone())?; + + self.current_cursor = result.next_cursor; + self.has_next_page = result.has_next_page; + self.last_response = result.response; + self.last_status_code = result.status_code; + self.last_headers = result.headers; + + Ok(result.items) + } + + /// Get all remaining items by loading all pages + pub fn collect_all(&mut self) -> Result, ApiError> { + let mut all_items = Vec::new(); + + // Add items from current page + while let Some(item) = self.current_page.pop_front() { + all_items.push(item); + } + + // Load all remaining pages + while self.has_next_page { + let page_items = self.next_page()?; + all_items.extend(page_items); + } + + Ok(all_items) + } +} + +impl Iterator for SyncPaginator { + type Item = Result; + + fn next(&mut self) -> Option { + // If we have items in the current page, return the next one + if let Some(item) = self.current_page.pop_front() { + return Some(Ok(item)); + } + + // If we have no more pages to load, we're done + if !self.has_next_page { + return None; + } + + // Load the next page + match (self.page_loader)(self.http_client.clone(), self.current_cursor.clone()) { + Ok(result) => { + self.current_page.extend(result.items); + self.current_cursor = result.next_cursor; + self.has_next_page = result.has_next_page; + self.last_response = result.response; + self.last_status_code = result.status_code; + self.last_headers = result.headers; + + // Return the first item from the newly loaded page + self.current_page.pop_front().map(Ok) + } + Err(e) => Some(Err(e)), + } + } +} + +/// Trait for types that can provide pagination metadata +pub trait Paginated { + /// Extract the items from this page + fn items(&self) -> &[T]; + + /// Get the cursor for the next page, if any + fn next_cursor(&self) -> Option<&str>; + + /// Check if there's a next page available + fn has_next_page(&self) -> bool; +} + +/// Trait for types that can provide offset-based pagination metadata +pub trait OffsetPaginated { + /// Extract the items from this page + fn items(&self) -> &[T]; + + /// Check if there's a next page available + fn has_next_page(&self) -> bool; + + /// Get the current page size (for calculating next offset) + fn page_size(&self) -> usize { + self.items().len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ClientConfig; + + fn make_http_client() -> Arc { + Arc::new( + HttpClient::new(ClientConfig::default()).expect("Failed to create test HttpClient"), + ) + } + + // =========================== + // SyncPaginator tests + // =========================== + + #[test] + fn test_sync_paginator_has_next_page_initially() { + let client = make_http_client(); + let paginator = SyncPaginator::::new( + client, + |_client, _cursor| { + Ok(PaginationResult { + items: vec![], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }) + }, + None, + ) + .unwrap(); + assert!(paginator.has_next_page()); + } + + #[test] + fn test_sync_paginator_single_page() { + let client = make_http_client(); + let mut paginator = SyncPaginator::new( + client, + |_client, _cursor| { + Ok(PaginationResult { + items: vec!["a".to_string(), "b".to_string()], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }) + }, + None, + ) + .unwrap(); + + let page = paginator.next_page().unwrap(); + assert_eq!(page, vec!["a".to_string(), "b".to_string()]); + assert!(!paginator.has_next_page()); + } + + #[test] + fn test_sync_paginator_exhausted_returns_empty() { + let client = make_http_client(); + let mut paginator = SyncPaginator::new( + client, + |_client, _cursor| { + Ok(PaginationResult { + items: vec!["a".to_string()], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }) + }, + None, + ) + .unwrap(); + + let _ = paginator.next_page().unwrap(); + let empty = paginator.next_page().unwrap(); + assert!(empty.is_empty()); + } + + #[test] + fn test_sync_paginator_multiple_pages() { + let client = make_http_client(); + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = call_count.clone(); + + let mut paginator = SyncPaginator::new( + client, + move |_client, cursor| { + let call = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match call { + 0 => { + assert!(cursor.is_none()); + Ok(PaginationResult { + items: vec![1, 2], + next_cursor: Some("page2".to_string()), + has_next_page: true, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }) + } + 1 => { + assert_eq!(cursor, Some("page2".to_string())); + Ok(PaginationResult { + items: vec![3, 4], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }) + } + _ => panic!("Unexpected call"), + } + }, + None, + ) + .unwrap(); + + let page1 = paginator.next_page().unwrap(); + assert_eq!(page1, vec![1, 2]); + assert!(paginator.has_next_page()); + + let page2 = paginator.next_page().unwrap(); + assert_eq!(page2, vec![3, 4]); + assert!(!paginator.has_next_page()); + } + + #[test] + fn test_sync_paginator_collect_all() { + let client = make_http_client(); + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = call_count.clone(); + + let mut paginator = SyncPaginator::new( + client, + move |_client, _cursor| { + let call = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match call { + 0 => Ok(PaginationResult { + items: vec![1, 2], + next_cursor: Some("next".to_string()), + has_next_page: true, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }), + 1 => Ok(PaginationResult { + items: vec![3], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }), + _ => panic!("Unexpected call"), + } + }, + None, + ) + .unwrap(); + + let all = paginator.collect_all().unwrap(); + assert_eq!(all, vec![1, 2, 3]); + } + + #[test] + fn test_sync_paginator_iterator() { + let client = make_http_client(); + let call_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = call_count.clone(); + + let paginator = SyncPaginator::new( + client, + move |_client, _cursor| { + let call = count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match call { + 0 => Ok(PaginationResult { + items: vec![10, 20], + next_cursor: Some("p2".to_string()), + has_next_page: true, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }), + 1 => Ok(PaginationResult { + items: vec![30], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }), + _ => panic!("Unexpected call"), + } + }, + None, + ) + .unwrap(); + + let items: Vec = paginator.map(|r| r.unwrap()).collect(); + assert_eq!(items, vec![10, 20, 30]); + } + + #[test] + fn test_sync_paginator_error_propagation() { + let client = make_http_client(); + let mut paginator = SyncPaginator::::new( + client, + |_client, _cursor| Err(ApiError::Configuration("test error".to_string())), + None, + ) + .unwrap(); + + let result = paginator.next_page(); + assert!(result.is_err()); + } + + #[test] + fn test_sync_paginator_iterator_error() { + let client = make_http_client(); + let mut paginator = SyncPaginator::::new( + client, + |_client, _cursor| Err(ApiError::Configuration("test error".to_string())), + None, + ) + .unwrap(); + + let item = paginator.next(); + assert!(item.is_some()); + assert!(item.unwrap().is_err()); + } + + #[test] + fn test_sync_paginator_with_initial_cursor() { + let client = make_http_client(); + let mut paginator = SyncPaginator::new( + client, + |_client, cursor| { + assert_eq!(cursor, Some("start_here".to_string())); + Ok(PaginationResult { + items: vec!["item".to_string()], + next_cursor: None, + has_next_page: false, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }) + }, + Some("start_here".to_string()), + ) + .unwrap(); + + let page = paginator.next_page().unwrap(); + assert_eq!(page, vec!["item".to_string()]); + } + + // =========================== + // PaginationResult tests + // =========================== + + #[test] + fn test_pagination_result_fields() { + let result = PaginationResult { + items: vec![1, 2, 3], + next_cursor: Some("abc".to_string()), + has_next_page: true, + response: None, + status_code: 200, + headers: HeaderMap::new(), + }; + assert_eq!(result.items.len(), 3); + assert_eq!(result.next_cursor, Some("abc".to_string())); + assert!(result.has_next_page); + } + + // =========================== + // Trait tests + // =========================== + + struct MockPage { + data: Vec, + cursor: Option, + has_more: bool, + } + + impl Paginated for MockPage { + fn items(&self) -> &[String] { + &self.data + } + fn next_cursor(&self) -> Option<&str> { + self.cursor.as_deref() + } + fn has_next_page(&self) -> bool { + self.has_more + } + } + + impl OffsetPaginated for MockPage { + fn items(&self) -> &[String] { + &self.data + } + fn has_next_page(&self) -> bool { + self.has_more + } + } + + #[test] + fn test_paginated_trait() { + let page = MockPage { + data: vec!["a".to_string(), "b".to_string()], + cursor: Some("next".to_string()), + has_more: true, + }; + assert_eq!(Paginated::items(&page).len(), 2); + assert_eq!(page.next_cursor(), Some("next")); + assert!(Paginated::has_next_page(&page)); + } + + #[test] + fn test_offset_paginated_default_page_size() { + let page = MockPage { + data: vec!["a".to_string(), "b".to_string(), "c".to_string()], + cursor: None, + has_more: false, + }; + assert_eq!(OffsetPaginated::page_size(&page), 3); + } +} diff --git a/agentmail-sdk/src/core/query_parameter_builder.rs b/agentmail-sdk/src/core/query_parameter_builder.rs new file mode 100644 index 0000000..6f1a697 --- /dev/null +++ b/agentmail-sdk/src/core/query_parameter_builder.rs @@ -0,0 +1,576 @@ +use chrono::{DateTime, TimeZone}; +use serde::Serialize; + +/// Modern query builder with type-safe method chaining +/// Provides a clean, Swift-like API for building HTTP query parameters +#[derive(Debug, Default)] +pub struct QueryBuilder { + params: Vec<(String, String)>, +} + +impl QueryBuilder { + /// Create a new query parameter builder + pub fn new() -> Self { + Self::default() + } + + /// Add a string parameter (accept both required/optional) + pub fn string(mut self, key: &str, value: impl Into>) -> Self { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v)); + } + self + } + + /// Add multiple string parameters with the same key (for allow-multiple query params) + /// Accepts both Vec and Vec>, adding each non-None value as a separate query parameter + pub fn string_array(mut self, key: &str, values: I) -> Self + where + I: IntoIterator, + T: Into>, + { + for value in values { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v)); + } + } + self + } + + /// Add an integer parameter (accept both required/optional) + pub fn int(mut self, key: &str, value: impl Into>) -> Self { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v.to_string())); + } + self + } + + /// Add multiple integer parameters with the same key (for allow-multiple query params) + /// Accepts both Vec and Vec>, adding each non-None value as a separate query parameter + pub fn int_array(mut self, key: &str, values: I) -> Self + where + I: IntoIterator, + T: Into>, + { + for value in values { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v.to_string())); + } + } + self + } + + /// Add a float parameter + pub fn float(mut self, key: &str, value: impl Into>) -> Self { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v.to_string())); + } + self + } + + /// Add multiple float parameters with the same key (for allow-multiple query params) + /// Accepts both Vec and Vec>, adding each non-None value as a separate query parameter + pub fn float_array(mut self, key: &str, values: I) -> Self + where + I: IntoIterator, + T: Into>, + { + for value in values { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v.to_string())); + } + } + self + } + + /// Add a boolean parameter + pub fn bool(mut self, key: &str, value: impl Into>) -> Self { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v.to_string())); + } + self + } + + /// Add multiple boolean parameters with the same key (for allow-multiple query params) + /// Accepts both Vec and Vec>, adding each non-None value as a separate query parameter + pub fn bool_array(mut self, key: &str, values: I) -> Self + where + I: IntoIterator, + T: Into>, + { + for value in values { + if let Some(v) = value.into() { + self.params.push((key.to_string(), v.to_string())); + } + } + self + } + + /// Add a datetime parameter (any DateTime timezone) + pub fn datetime( + mut self, + key: &str, + value: impl Into>>, + ) -> Self + where + Tz::Offset: std::fmt::Display, + { + if let Some(v) = value.into() { + self.params.push(( + key.to_string(), + v.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + )); + } + self + } + + /// Add a date parameter (converts NaiveDate to DateTime) + pub fn date(mut self, key: &str, value: impl Into>) -> Self { + if let Some(v) = value.into() { + // Convert NaiveDate to DateTime at start of day + let datetime = v.and_hms_opt(0, 0, 0).unwrap().and_utc(); + self.params.push(( + key.to_string(), + datetime.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + )); + } + self + } + + /// Add any serializable parameter (for enums and complex types) + pub fn serialize(mut self, key: &str, value: Option) -> Self { + if let Some(v) = value { + // For enums that implement Display, use the Display implementation + // to avoid JSON quotes in query parameters + if let Ok(serialized) = serde_json::to_string(&v) { + // Remove JSON quotes if the value is a simple string + let cleaned = if serialized.starts_with('"') && serialized.ends_with('"') { + serialized.trim_matches('"').to_string() + } else { + serialized + }; + self.params.push((key.to_string(), cleaned)); + } + } + self + } + + /// Add multiple serializable parameters with the same key (for allow-multiple query params with enums) + /// Accepts both Vec and Vec>, adding each non-None value as a separate query parameter + pub fn serialize_array( + mut self, + key: &str, + values: impl IntoIterator, + ) -> Self { + for value in values { + if let Ok(serialized) = serde_json::to_string(&value) { + // Skip null values (from Option::None) + if serialized == "null" { + continue; + } + // Remove JSON quotes if the value is a simple string + let cleaned = if serialized.starts_with('"') && serialized.ends_with('"') { + serialized.trim_matches('"').to_string() + } else { + serialized + }; + self.params.push((key.to_string(), cleaned)); + } + } + self + } + + /// Parse and add a structured query string + /// Handles complex query patterns like: + /// - "key:value" patterns + /// - "key:value1,value2" (comma-separated values) + /// - Quoted values: "key:\"value with spaces\"" + /// - Space-separated terms (treated as AND logic) + pub fn structured_query(mut self, key: &str, value: impl Into>) -> Self { + if let Some(query_str) = value.into() { + if let Ok(parsed_params) = parse_structured_query(&query_str) { + self.params.extend(parsed_params); + } else { + // Fall back to simple query parameter if parsing fails + self.params.push((key.to_string(), query_str)); + } + } + self + } + + /// Build the final query parameters + pub fn build(self) -> Option> { + if self.params.is_empty() { + None + } else { + Some(self.params) + } + } +} + +/// Errors that can occur during structured query parsing +#[derive(Debug)] +pub enum QueryBuilderError { + InvalidQuerySyntax(String), +} + +impl std::fmt::Display for QueryBuilderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + QueryBuilderError::InvalidQuerySyntax(msg) => { + write!(f, "Invalid query syntax: {}", msg) + } + } + } +} + +impl std::error::Error for QueryBuilderError {} + +/// Parse structured query strings like "key:value key2:value1,value2" +/// Used for complex filtering patterns in APIs like Foxglove +/// +/// Supported patterns: +/// - Simple: "status:active" +/// - Multiple values: "type:sensor,camera" +/// - Quoted values: "location:\"New York\"" +/// - Complex: "status:active type:sensor location:\"San Francisco\"" +pub fn parse_structured_query(query: &str) -> Result, QueryBuilderError> { + let mut params = Vec::new(); + let terms = tokenize_query(query); + + for term in terms { + if let Some((key, values)) = term.split_once(':') { + // Handle comma-separated values + for value in values.split(',') { + let clean_value = value.trim_matches('"'); // Remove quotes + params.push((key.to_string(), clean_value.to_string())); + } + } else { + // For terms without colons, return error to be explicit about expected format + return Err(QueryBuilderError::InvalidQuerySyntax(format!( + "Cannot parse term '{}' - expected 'key:value' format for structured queries", + term + ))); + } + } + + Ok(params) +} + +/// Tokenize a query string, properly handling quoted strings +fn tokenize_query(input: &str) -> Vec { + let mut tokens = Vec::new(); + let mut current_token = String::new(); + let mut in_quotes = false; + let mut chars = input.chars().peekable(); + + while let Some(c) = chars.next() { + match c { + '"' => { + // Toggle quote state and include the quote in the token + in_quotes = !in_quotes; + current_token.push(c); + } + ' ' if !in_quotes => { + // Space outside quotes - end current token + if !current_token.is_empty() { + tokens.push(current_token.trim().to_string()); + current_token.clear(); + } + } + _ => { + // Any other character (including spaces inside quotes) + current_token.push(c); + } + } + } + + // Add the last token if there is one + if !current_token.is_empty() { + tokens.push(current_token.trim().to_string()); + } + + tokens +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{NaiveDate, TimeZone, Utc}; + + // =========================== + // QueryBuilder tests + // =========================== + + #[test] + fn test_empty_builder_returns_none() { + let result = QueryBuilder::new().build(); + assert!(result.is_none()); + } + + #[test] + fn test_string_param_some() { + let result = QueryBuilder::new() + .string("name", Some("alice".to_string())) + .build(); + assert_eq!( + result, + Some(vec![("name".to_string(), "alice".to_string())]) + ); + } + + #[test] + fn test_string_param_none_skipped() { + let result = QueryBuilder::new().string("name", None::).build(); + assert!(result.is_none()); + } + + #[test] + fn test_int_param() { + let result = QueryBuilder::new().int("page", Some(42i64)).build(); + assert_eq!(result, Some(vec![("page".to_string(), "42".to_string())])); + } + + #[test] + fn test_int_param_none_skipped() { + let result = QueryBuilder::new().int("page", None::).build(); + assert!(result.is_none()); + } + + #[test] + fn test_float_param() { + let result = QueryBuilder::new().float("score", Some(3.14f64)).build(); + assert_eq!( + result, + Some(vec![("score".to_string(), "3.14".to_string())]) + ); + } + + #[test] + fn test_bool_param() { + let result = QueryBuilder::new().bool("active", Some(true)).build(); + assert_eq!( + result, + Some(vec![("active".to_string(), "true".to_string())]) + ); + } + + #[test] + fn test_datetime_param_formats_rfc3339() { + let dt = Utc.with_ymd_and_hms(2024, 1, 15, 9, 30, 0).unwrap(); + let result = QueryBuilder::new().datetime("since", Some(dt)).build(); + assert_eq!( + result, + Some(vec![( + "since".to_string(), + "2024-01-15T09:30:00Z".to_string() + )]) + ); + } + + #[test] + fn test_date_param_converts_to_midnight_utc() { + let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(); + let result = QueryBuilder::new().date("on", Some(date)).build(); + assert_eq!( + result, + Some(vec![("on".to_string(), "2024-01-15T00:00:00Z".to_string())]) + ); + } + + #[test] + fn test_string_array_multiple_entries() { + let result = QueryBuilder::new() + .string_array( + "tag", + vec!["a".to_string(), "b".to_string(), "c".to_string()], + ) + .build(); + assert_eq!( + result, + Some(vec![ + ("tag".to_string(), "a".to_string()), + ("tag".to_string(), "b".to_string()), + ("tag".to_string(), "c".to_string()), + ]) + ); + } + + #[test] + fn test_int_array() { + let result = QueryBuilder::new() + .int_array("ids", vec![1i64, 2, 3]) + .build(); + assert_eq!( + result, + Some(vec![ + ("ids".to_string(), "1".to_string()), + ("ids".to_string(), "2".to_string()), + ("ids".to_string(), "3".to_string()), + ]) + ); + } + + #[test] + fn test_float_array() { + let result = QueryBuilder::new() + .float_array("scores", vec![1.1f64, 2.2]) + .build(); + assert_eq!( + result, + Some(vec![ + ("scores".to_string(), "1.1".to_string()), + ("scores".to_string(), "2.2".to_string()), + ]) + ); + } + + #[test] + fn test_bool_array() { + let result = QueryBuilder::new() + .bool_array("flags", vec![true, false]) + .build(); + assert_eq!( + result, + Some(vec![ + ("flags".to_string(), "true".to_string()), + ("flags".to_string(), "false".to_string()), + ]) + ); + } + + #[test] + fn test_serialize_strips_json_quotes() { + let result = QueryBuilder::new() + .serialize("status", Some("active")) + .build(); + assert_eq!( + result, + Some(vec![("status".to_string(), "active".to_string())]) + ); + } + + #[test] + fn test_serialize_none_skipped() { + let result = QueryBuilder::new() + .serialize::("status", None) + .build(); + assert!(result.is_none()); + } + + #[test] + fn test_serialize_numeric_no_quotes() { + let result = QueryBuilder::new().serialize("count", Some(42)).build(); + assert_eq!(result, Some(vec![("count".to_string(), "42".to_string())])); + } + + #[test] + fn test_serialize_array_skips_null() { + let values: Vec> = vec![Some("a"), None, Some("b")]; + let result = QueryBuilder::new().serialize_array("items", values).build(); + assert_eq!( + result, + Some(vec![ + ("items".to_string(), "a".to_string()), + ("items".to_string(), "b".to_string()), + ]) + ); + } + + #[test] + fn test_method_chaining() { + let result = QueryBuilder::new() + .string("name", Some("alice".to_string())) + .int("page", Some(1i64)) + .bool("active", Some(true)) + .build(); + assert_eq!( + result, + Some(vec![ + ("name".to_string(), "alice".to_string()), + ("page".to_string(), "1".to_string()), + ("active".to_string(), "true".to_string()), + ]) + ); + } + + // =========================== + // parse_structured_query tests + // =========================== + + #[test] + fn test_parse_simple_key_value() { + let result = parse_structured_query("status:active").unwrap(); + assert_eq!(result, vec![("status".to_string(), "active".to_string())]); + } + + #[test] + fn test_parse_comma_separated_values() { + let result = parse_structured_query("type:sensor,camera").unwrap(); + assert_eq!( + result, + vec![ + ("type".to_string(), "sensor".to_string()), + ("type".to_string(), "camera".to_string()), + ] + ); + } + + #[test] + fn test_parse_multiple_terms() { + let result = parse_structured_query("status:active type:sensor").unwrap(); + assert_eq!( + result, + vec![ + ("status".to_string(), "active".to_string()), + ("type".to_string(), "sensor".to_string()), + ] + ); + } + + #[test] + fn test_parse_quoted_value() { + let result = parse_structured_query("location:\"New York\"").unwrap(); + assert_eq!( + result, + vec![("location".to_string(), "New York".to_string())] + ); + } + + #[test] + fn test_parse_bare_word_returns_error() { + let result = parse_structured_query("bareword"); + assert!(result.is_err()); + } + + #[test] + fn test_structured_query_builder_fallback() { + // When parsing fails, structured_query falls back to simple param + let result = QueryBuilder::new() + .structured_query("q", Some("bareword".to_string())) + .build(); + assert_eq!( + result, + Some(vec![("q".to_string(), "bareword".to_string())]) + ); + } + + #[test] + fn test_structured_query_builder_parses() { + let result = QueryBuilder::new() + .structured_query("q", Some("status:active".to_string())) + .build(); + assert_eq!( + result, + Some(vec![("status".to_string(), "active".to_string())]) + ); + } + + #[test] + fn test_structured_query_none_skipped() { + let result = QueryBuilder::new() + .structured_query("q", None::) + .build(); + assert!(result.is_none()); + } +} diff --git a/agentmail-sdk/src/core/request_options.rs b/agentmail-sdk/src/core/request_options.rs new file mode 100644 index 0000000..80508c9 --- /dev/null +++ b/agentmail-sdk/src/core/request_options.rs @@ -0,0 +1,176 @@ +use std::collections::HashMap; +/// Options for customizing individual requests +#[derive(Debug, Clone, Default)] +pub struct RequestOptions { + /// API key for authentication (overrides client-level API key) + pub api_key: Option, + /// Bearer token for authentication (overrides client-level token) + pub token: Option, + /// Maximum number of retry attempts for failed requests + pub max_retries: Option, + /// Request timeout in seconds (overrides client-level timeout) + pub timeout_seconds: Option, + /// Additional headers to include in the request + pub additional_headers: HashMap, + /// Additional query parameters to include in the request + pub additional_query_params: HashMap, +} + +impl RequestOptions { + pub fn new() -> Self { + Self::default() + } + + pub fn api_key(mut self, key: impl Into) -> Self { + self.api_key = Some(key.into()); + self + } + + pub fn token(mut self, token: impl Into) -> Self { + self.token = Some(token.into()); + self + } + + pub fn max_retries(mut self, retries: u32) -> Self { + self.max_retries = Some(retries); + self + } + + pub fn timeout_seconds(mut self, timeout: u64) -> Self { + self.timeout_seconds = Some(timeout); + self + } + + pub fn additional_header(mut self, key: impl Into, value: impl Into) -> Self { + self.additional_headers.insert(key.into(), value.into()); + self + } + + pub fn additional_query_param( + mut self, + key: impl Into, + value: impl Into, + ) -> Self { + self.additional_query_params + .insert(key.into(), value.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_has_no_values() { + let opts = RequestOptions::default(); + assert!(opts.api_key.is_none()); + assert!(opts.token.is_none()); + assert!(opts.max_retries.is_none()); + assert!(opts.timeout_seconds.is_none()); + assert!(opts.additional_headers.is_empty()); + assert!(opts.additional_query_params.is_empty()); + } + + #[test] + fn test_new_equals_default() { + let opts = RequestOptions::new(); + assert!(opts.api_key.is_none()); + assert!(opts.token.is_none()); + assert!(opts.max_retries.is_none()); + assert!(opts.timeout_seconds.is_none()); + assert!(opts.additional_headers.is_empty()); + assert!(opts.additional_query_params.is_empty()); + } + + #[test] + fn test_api_key() { + let opts = RequestOptions::new().api_key("my-key"); + assert_eq!(opts.api_key, Some("my-key".to_string())); + } + + #[test] + fn test_token() { + let opts = RequestOptions::new().token("my-token"); + assert_eq!(opts.token, Some("my-token".to_string())); + } + + #[test] + fn test_max_retries() { + let opts = RequestOptions::new().max_retries(3); + assert_eq!(opts.max_retries, Some(3)); + } + + #[test] + fn test_timeout_seconds() { + let opts = RequestOptions::new().timeout_seconds(30); + assert_eq!(opts.timeout_seconds, Some(30)); + } + + #[test] + fn test_additional_header() { + let opts = RequestOptions::new().additional_header("X-Custom", "value"); + assert_eq!( + opts.additional_headers.get("X-Custom"), + Some(&"value".to_string()) + ); + } + + #[test] + fn test_additional_headers_accumulate() { + let opts = RequestOptions::new() + .additional_header("X-First", "1") + .additional_header("X-Second", "2"); + assert_eq!(opts.additional_headers.len(), 2); + assert_eq!( + opts.additional_headers.get("X-First"), + Some(&"1".to_string()) + ); + assert_eq!( + opts.additional_headers.get("X-Second"), + Some(&"2".to_string()) + ); + } + + #[test] + fn test_additional_query_param() { + let opts = RequestOptions::new().additional_query_param("page", "1"); + assert_eq!( + opts.additional_query_params.get("page"), + Some(&"1".to_string()) + ); + } + + #[test] + fn test_additional_query_params_accumulate() { + let opts = RequestOptions::new() + .additional_query_param("page", "1") + .additional_query_param("limit", "10"); + assert_eq!(opts.additional_query_params.len(), 2); + assert_eq!( + opts.additional_query_params.get("page"), + Some(&"1".to_string()) + ); + assert_eq!( + opts.additional_query_params.get("limit"), + Some(&"10".to_string()) + ); + } + + #[test] + fn test_full_method_chaining() { + let opts = RequestOptions::new() + .api_key("key") + .token("tok") + .max_retries(5) + .timeout_seconds(60) + .additional_header("X-Foo", "bar") + .additional_query_param("q", "search"); + assert_eq!(opts.api_key, Some("key".to_string())); + assert_eq!(opts.token, Some("tok".to_string())); + assert_eq!(opts.max_retries, Some(5)); + assert_eq!(opts.timeout_seconds, Some(60)); + assert_eq!(opts.additional_headers.len(), 1); + assert_eq!(opts.additional_query_params.len(), 1); + } +} diff --git a/agentmail-sdk/src/core/utils.rs b/agentmail-sdk/src/core/utils.rs new file mode 100644 index 0000000..323676f --- /dev/null +++ b/agentmail-sdk/src/core/utils.rs @@ -0,0 +1,77 @@ +/// URL building utilities +/// Safely join a base URL with a path, handling slashes properly +/// +/// # Examples +/// ``` +/// use example_api::utils::url::join_url; +/// +/// assert_eq!(join_url("https://api.example.com", "users"), "https://api.example.com/users"); +/// assert_eq!(join_url("https://api.example.com/", "users"), "https://api.example.com/users"); +/// assert_eq!(join_url("https://api.example.com", "/users"), "https://api.example.com/users"); +/// assert_eq!(join_url("https://api.example.com/", "/users"), "https://api.example.com/users"); +/// ``` +pub fn join_url(base_url: &str, path: &str) -> String { + format!( + "{}/{}", + base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_join_url_no_slashes() { + assert_eq!( + join_url("https://api.example.com", "users"), + "https://api.example.com/users" + ); + } + + #[test] + fn test_join_url_trailing_slash_on_base() { + assert_eq!( + join_url("https://api.example.com/", "users"), + "https://api.example.com/users" + ); + } + + #[test] + fn test_join_url_leading_slash_on_path() { + assert_eq!( + join_url("https://api.example.com", "/users"), + "https://api.example.com/users" + ); + } + + #[test] + fn test_join_url_both_slashes() { + assert_eq!( + join_url("https://api.example.com/", "/users"), + "https://api.example.com/users" + ); + } + + #[test] + fn test_join_url_multi_segment_path() { + assert_eq!( + join_url("https://api.example.com", "v1/users/123"), + "https://api.example.com/v1/users/123" + ); + } + + #[test] + fn test_join_url_empty_path() { + assert_eq!( + join_url("https://api.example.com", ""), + "https://api.example.com/" + ); + } + + #[test] + fn test_join_url_empty_base() { + assert_eq!(join_url("", "users"), "/users"); + } +} diff --git a/agentmail-sdk/src/error.rs b/agentmail-sdk/src/error.rs new file mode 100644 index 0000000..d4bbf0e --- /dev/null +++ b/agentmail-sdk/src/error.rs @@ -0,0 +1,267 @@ +use crate::prelude::*; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("BadRequestError: Bad request - {message}")] + BadRequestError { + message: String, + name: Option, + code: Option, + errors: Option, + fix: Option, + docs: Option, + }, + #[error("UnprocessableEntityError: Unprocessable entity - {message}")] + UnprocessableEntityError { + message: String, + name: Option, + code: Option, + fix: Option, + docs: Option, + }, + #[error("NotFoundError: Resource not found - {message}")] + NotFoundError { + message: String, + name: Option, + code: Option, + fix: Option, + docs: Option, + }, + #[error("ConflictError: Conflict - {message}")] + ConflictError { + message: String, + name: Option, + code: Option, + fix: Option, + docs: Option, + }, + #[error("ForbiddenError: Access forbidden - {message}")] + ForbiddenError { + message: String, + name: Option, + code: Option, + fix: Option, + docs: Option, + }, + #[error("HTTP error {status}: {message}")] + Http { status: u16, message: String }, + #[error("Network error: {0}")] + Network(reqwest::Error), + #[error("Request executor error: {0}")] + Executor(Box), + #[error("Serialization error: {0}")] + Serialization(serde_json::Error), + #[error("Configuration error: {0}")] + Configuration(String), + #[error("Invalid header value")] + InvalidHeader, + #[error("Could not clone request for retry")] + RequestClone, + #[error("SSE stream terminated")] + StreamTerminated, + #[error("SSE stream timed out waiting for next event")] + StreamTimeout, + #[error("SSE parse error: {0}")] + SseParseError(String), +} + +impl ApiError { + pub fn from_response(status_code: u16, body: Option<&str>) -> Self { + match status_code { + 400 => { + // Parse error body for BadRequestError; + if let Some(body_str) = body { + if let Ok(parsed) = serde_json::from_str::(body_str) { + return Self::BadRequestError { + message: parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(), + name: parsed + .get("name") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + code: parsed + .get("code") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + errors: parsed.get("errors").and_then(|v| { + serde_json::from_value::(v.clone()).ok() + }), + fix: parsed + .get("fix") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + docs: parsed + .get("docs") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + }; + } + } + return Self::BadRequestError { + message: body.unwrap_or("Unknown error").to_string(), + name: None, + code: None, + errors: None, + fix: None, + docs: None, + }; + } + 422 => { + // Parse error body for UnprocessableEntityError; + if let Some(body_str) = body { + if let Ok(parsed) = serde_json::from_str::(body_str) { + return Self::UnprocessableEntityError { + message: parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(), + name: parsed + .get("name") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + code: parsed + .get("code") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + fix: parsed + .get("fix") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + docs: parsed + .get("docs") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + }; + } + } + return Self::UnprocessableEntityError { + message: body.unwrap_or("Unknown error").to_string(), + name: None, + code: None, + fix: None, + docs: None, + }; + } + 404 => { + // Parse error body for NotFoundError; + if let Some(body_str) = body { + if let Ok(parsed) = serde_json::from_str::(body_str) { + return Self::NotFoundError { + message: parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(), + name: parsed + .get("name") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + code: parsed + .get("code") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + fix: parsed + .get("fix") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + docs: parsed + .get("docs") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + }; + } + } + return Self::NotFoundError { + message: body.unwrap_or("Unknown error").to_string(), + name: None, + code: None, + fix: None, + docs: None, + }; + } + 409 => { + // Parse error body for ConflictError; + if let Some(body_str) = body { + if let Ok(parsed) = serde_json::from_str::(body_str) { + return Self::ConflictError { + message: parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(), + name: parsed + .get("name") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + code: parsed + .get("code") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + fix: parsed + .get("fix") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + docs: parsed + .get("docs") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + }; + } + } + return Self::ConflictError { + message: body.unwrap_or("Unknown error").to_string(), + name: None, + code: None, + fix: None, + docs: None, + }; + } + 403 => { + // Parse error body for ForbiddenError; + if let Some(body_str) = body { + if let Ok(parsed) = serde_json::from_str::(body_str) { + return Self::ForbiddenError { + message: parsed + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error") + .to_string(), + name: parsed + .get("name") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + code: parsed + .get("code") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + fix: parsed + .get("fix") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + docs: parsed + .get("docs") + .and_then(|v| serde_json::from_value::(v.clone()).ok()), + }; + } + } + return Self::ForbiddenError { + message: body.unwrap_or("Unknown error").to_string(), + name: None, + code: None, + fix: None, + docs: None, + }; + } + _ => Self::Http { + status: status_code, + message: body.unwrap_or("Unknown error").to_string(), + }, + } + } +} + +/// Error returned when a required field was not set on a builder. +#[derive(Debug)] +pub struct BuildError { + field: &'static str, +} + +impl BuildError { + pub fn missing_field(field: &'static str) -> Self { + Self { field } + } +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "`{}` was not set but is required", self.field) + } +} + +impl std::error::Error for BuildError {} diff --git a/agentmail-sdk/src/lib.rs b/agentmail-sdk/src/lib.rs new file mode 100644 index 0000000..3a93951 --- /dev/null +++ b/agentmail-sdk/src/lib.rs @@ -0,0 +1,48 @@ +//! # AgentMail SDK +//! +//! The official Rust SDK for the AgentMail. +//! +//! ## Getting Started +//! +//! ```rust +//! use agentmail_sdk::prelude::*; +//! +//! #[tokio::main] +//! async fn main() { +//! let config = ClientConfig { +//! token: Some("".to_string()), +//! ..Default::default() +//! }; +//! let client = AgentmailClient::new(config).expect("Failed to build client"); +//! client +//! .inboxes +//! .list( +//! &InboxesListQueryRequest { +//! ..Default::default() +//! }, +//! None, +//! ) +//! .await; +//! } +//! ``` +//! +//! ## Modules +//! +//! - [`api`] - Core API types and models +//! - [`client`] - Client implementations +//! - [`config`] - Configuration options +//! - [`core`] - Core utilities and infrastructure +//! - [`error`] - Error types and handling +//! - [`prelude`] - Common imports for convenience + +pub mod api; +pub mod client; +pub mod config; +pub mod core; +pub mod error; +pub mod prelude; + +pub use client::*; +pub use config::*; +pub use core::*; +pub use error::{ApiError, BuildError}; diff --git a/agentmail-sdk/src/prelude.rs b/agentmail-sdk/src/prelude.rs new file mode 100644 index 0000000..7e7388c --- /dev/null +++ b/agentmail-sdk/src/prelude.rs @@ -0,0 +1,2 @@ +pub use agentmail_types::*; +pub use std::collections::{HashMap, HashSet}; diff --git a/agentmail-types/Cargo.toml b/agentmail-types/Cargo.toml new file mode 100644 index 0000000..3ec518d --- /dev/null +++ b/agentmail-types/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "agentmail_types" +version = "0.0.0" +edition = "2021" + +[lib] +doctest = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } +base64 = "0.22" +num-bigint = { version = "0.4", features = ["serde"] } +ordered-float = { version = "4.5", features = ["serde"] } diff --git a/agentmail-types/src/core/base64_bytes.rs b/agentmail-types/src/core/base64_bytes.rs new file mode 100644 index 0000000..d163fa6 --- /dev/null +++ b/agentmail-types/src/core/base64_bytes.rs @@ -0,0 +1,147 @@ +//! Base64 encoding/decoding module for Vec fields +//! +//! This module provides serde helpers for serializing and deserializing +//! Vec fields as base64-encoded strings in JSON. +//! +//! Usage: +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyStruct { +//! #[serde(with = "crate::core::base64_bytes")] +//! data: Vec, +//! } +//! ``` + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::{self, Deserialize, Deserializer, Serializer}; + +/// Serialize a Vec as a base64-encoded string +pub fn serialize(bytes: &Vec, serializer: S) -> Result +where + S: Serializer, +{ + let encoded = STANDARD.encode(bytes); + serializer.serialize_str(&encoded) +} + +/// Deserialize a base64-encoded string into Vec +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + STANDARD.decode(&s).map_err(serde::de::Error::custom) +} + +/// Module for optional Vec fields with base64 encoding +pub mod option { + use super::*; + + pub fn serialize(bytes: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match bytes { + Some(b) => { + let encoded = STANDARD.encode(b); + serializer.serialize_some(&encoded) + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => STANDARD + .decode(&s) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + #[serde(with = "super")] + data: Vec, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStructOptional { + #[serde(default)] + #[serde(with = "super::option")] + #[serde(skip_serializing_if = "Option::is_none")] + data: Option>, + } + + #[test] + fn test_serialize_bytes() { + let test = TestStruct { + data: b"Hello world!".to_vec(), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"data":"SGVsbG8gd29ybGQh"}"#); + } + + #[test] + fn test_deserialize_bytes() { + let json = r#"{"data":"SGVsbG8gd29ybGQh"}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.data, b"Hello world!"); + } + + #[test] + fn test_roundtrip() { + let original = TestStruct { + data: vec![0, 1, 2, 255, 254, 253], + }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: TestStruct = serde_json::from_str(&json).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_optional_some() { + let test = TestStructOptional { + data: Some(b"test".to_vec()), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"data":"dGVzdA=="}"#); + + let decoded: TestStructOptional = serde_json::from_str(&json).unwrap(); + assert_eq!(test, decoded); + } + + #[test] + fn test_optional_none() { + let test = TestStructOptional { data: None }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{}"#); + } + + #[test] + fn test_optional_deserialize_null() { + let json = r#"{"data":null}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.data, None); + } + + #[test] + fn test_optional_deserialize_missing() { + let json = r#"{}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.data, None); + } +} diff --git a/agentmail-types/src/core/bigint_string.rs b/agentmail-types/src/core/bigint_string.rs new file mode 100644 index 0000000..aad9e42 --- /dev/null +++ b/agentmail-types/src/core/bigint_string.rs @@ -0,0 +1,160 @@ +//! BigInt string encoding/decoding module for num_bigint::BigInt fields +//! +//! This module provides serde helpers for serializing and deserializing +//! BigInt fields as string representations in JSON. +//! +//! Usage: +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! use num_bigint::BigInt; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyStruct { +//! #[serde(with = "crate::core::bigint_string")] +//! value: BigInt, +//! } +//! ``` + +use num_bigint::BigInt; +use serde::{self, Deserialize, Deserializer, Serializer}; +use std::str::FromStr; + +/// Serialize a BigInt as a string +pub fn serialize(value: &BigInt, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +/// Deserialize a string into BigInt +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + BigInt::from_str(&s).map_err(serde::de::Error::custom) +} + +/// Module for optional BigInt fields with string encoding +pub mod option { + use super::*; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(v) => serializer.serialize_some(&v.to_string()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => BigInt::from_str(&s) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + #[serde(with = "super")] + value: BigInt, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStructOptional { + #[serde(default)] + #[serde(with = "super::option")] + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + } + + #[test] + fn test_serialize_bigint() { + let test = TestStruct { + value: BigInt::from(1000000i64), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":"1000000"}"#); + } + + #[test] + fn test_deserialize_bigint() { + let json = r#"{"value":"1000000"}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, BigInt::from(1000000i64)); + } + + #[test] + fn test_roundtrip() { + let original = TestStruct { + value: BigInt::from(123456789012345678i64), + }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: TestStruct = serde_json::from_str(&json).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_large_number() { + // Test with a number larger than i64 max + let json = r#"{"value":"99999999999999999999999999999"}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + let expected = BigInt::from_str("99999999999999999999999999999").unwrap(); + assert_eq!(test.value, expected); + } + + #[test] + fn test_negative_number() { + let json = r#"{"value":"-1000000"}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, BigInt::from(-1000000i64)); + } + + #[test] + fn test_optional_some() { + let test = TestStructOptional { + value: Some(BigInt::from(1000000i64)), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":"1000000"}"#); + + let decoded: TestStructOptional = serde_json::from_str(&json).unwrap(); + assert_eq!(test, decoded); + } + + #[test] + fn test_optional_none() { + let test = TestStructOptional { value: None }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{}"#); + } + + #[test] + fn test_optional_deserialize_null() { + let json = r#"{"value":null}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, None); + } + + #[test] + fn test_optional_deserialize_missing() { + let json = r#"{}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, None); + } +} diff --git a/agentmail-types/src/core/flexible_datetime.rs b/agentmail-types/src/core/flexible_datetime.rs new file mode 100644 index 0000000..017ca43 --- /dev/null +++ b/agentmail-types/src/core/flexible_datetime.rs @@ -0,0 +1,265 @@ +//! Flexible datetime parsing module +//! +//! This module provides serde helpers for parsing datetime strings that may or may not +//! include a timezone suffix. It accepts both RFC3339 format (with Z or +00:00 suffix) +//! and ISO 8601 format without timezone (assuming UTC). +//! +//! Supported formats: +//! - `2024-01-15T09:30:00Z` (RFC3339 with Z) +//! - `2024-01-15T09:30:00+00:00` (RFC3339 with offset) +//! - `2024-01-15T09:30:00` (ISO 8601 without timezone, assumes UTC) +//! - `2024-01-15T09:30:00.123Z` (with fractional seconds and Z) +//! - `2024-01-15T09:30:00.123` (with fractional seconds, no timezone) +//! +//! Two submodules are provided: +//! - `utc`: Parses into `DateTime`, converting all datetimes to UTC +//! - `offset`: Parses into `DateTime`, preserving original timezone + +/// Module for DateTime with flexible parsing - converts all datetimes to UTC +pub mod utc { + use chrono::{DateTime, NaiveDateTime, Utc}; + use serde::{self, Deserialize, Deserializer, Serializer}; + + /// Serialize a DateTime to RFC3339 format + pub fn serialize(date: &DateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.to_rfc3339()) + } + + /// Deserialize a datetime string that may or may not include a timezone suffix. + /// If no timezone is present, UTC is assumed. All datetimes are converted to UTC. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + parse_flexible_datetime(&s).map_err(serde::de::Error::custom) + } + + /// Parse a datetime string flexibly, accepting both RFC3339 and plain ISO 8601 formats. + fn parse_flexible_datetime(s: &str) -> Result, String> { + // First, try parsing as RFC3339 (with timezone) + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt.with_timezone(&Utc)); + } + + // Try parsing as NaiveDateTime (without timezone) and assume UTC + // Try with fractional seconds first + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return Ok(naive.and_utc()); + } + + // Try without fractional seconds + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { + return Ok(naive.and_utc()); + } + + Err(format!( + "Failed to parse datetime '{}'. Expected RFC3339 format (e.g., '2024-01-15T09:30:00Z') \ + or ISO 8601 format (e.g., '2024-01-15T09:30:00')", + s + )) + } + + /// Module for optional DateTime fields with flexible parsing + pub mod option { + use super::*; + + pub fn serialize(date: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match date { + Some(dt) => serializer.serialize_some(&dt.to_rfc3339()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => parse_flexible_datetime(&s) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_parse_rfc3339_with_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00Z"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_rfc3339_with_offset() { + let result = parse_flexible_datetime("2024-01-15T09:30:00+00:00"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_without_timezone() { + let result = parse_flexible_datetime("2024-01-15T09:30:00"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_with_fractional_seconds() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_with_fractional_seconds_and_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123Z"); + assert!(result.is_ok()); + } + } +} + +/// Module for DateTime with flexible parsing - preserves original timezone +pub mod offset { + use chrono::{DateTime, FixedOffset, NaiveDateTime}; + use serde::{self, Deserialize, Deserializer, Serializer}; + + /// Serialize a DateTime to RFC3339 format + pub fn serialize(date: &DateTime, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&date.to_rfc3339()) + } + + /// Deserialize a datetime string that may or may not include a timezone suffix. + /// If no timezone is present, UTC (+00:00) is assumed. + /// The original timezone offset is preserved when present. + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + parse_flexible_datetime(&s).map_err(serde::de::Error::custom) + } + + /// Parse a datetime string flexibly, accepting both RFC3339 and plain ISO 8601 formats. + /// Preserves the original timezone offset when present, assumes UTC when not. + fn parse_flexible_datetime(s: &str) -> Result, String> { + // First, try parsing as RFC3339 (with timezone) - this preserves the original offset + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Ok(dt); + } + + // Try parsing as NaiveDateTime (without timezone) and assume UTC (+00:00) + let utc_offset = FixedOffset::east_opt(0).unwrap(); + + // Try with fractional seconds first + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") { + return Ok(naive.and_local_timezone(utc_offset).unwrap()); + } + + // Try without fractional seconds + if let Ok(naive) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") { + return Ok(naive.and_local_timezone(utc_offset).unwrap()); + } + + Err(format!( + "Failed to parse datetime '{}'. Expected RFC3339 format (e.g., '2024-01-15T09:30:00Z') \ + or ISO 8601 format (e.g., '2024-01-15T09:30:00')", + s + )) + } + + /// Module for optional DateTime fields with flexible parsing + pub mod option { + use super::*; + + pub fn serialize(date: &Option>, serializer: S) -> Result + where + S: Serializer, + { + match date { + Some(dt) => serializer.serialize_some(&dt.to_rfc3339()), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + let opt: Option = Option::deserialize(deserializer)?; + match opt { + Some(s) => parse_flexible_datetime(&s) + .map(Some) + .map_err(serde::de::Error::custom), + None => Ok(None), + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn test_parse_rfc3339_with_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00Z"); + assert!(result.is_ok()); + let dt = result.unwrap(); + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_parse_rfc3339_with_offset() { + let result = parse_flexible_datetime("2024-01-15T09:30:00-05:00"); + assert!(result.is_ok()); + let dt = result.unwrap(); + // -05:00 = -5 * 3600 = -18000 seconds + assert_eq!(dt.offset().local_minus_utc(), -18000); + } + + #[test] + fn test_parse_without_timezone() { + let result = parse_flexible_datetime("2024-01-15T09:30:00"); + assert!(result.is_ok()); + let dt = result.unwrap(); + // Should assume UTC (+00:00) + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_parse_with_fractional_seconds() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123"); + assert!(result.is_ok()); + let dt = result.unwrap(); + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_parse_with_fractional_seconds_and_z() { + let result = parse_flexible_datetime("2024-01-15T09:30:00.123Z"); + assert!(result.is_ok()); + let dt = result.unwrap(); + assert_eq!(dt.offset().local_minus_utc(), 0); + } + + #[test] + fn test_preserves_positive_offset() { + let result = parse_flexible_datetime("2024-01-15T09:30:00+09:00"); + assert!(result.is_ok()); + let dt = result.unwrap(); + // +09:00 = 9 * 3600 = 32400 seconds + assert_eq!(dt.offset().local_minus_utc(), 32400); + } + } +} diff --git a/agentmail-types/src/core/mod.rs b/agentmail-types/src/core/mod.rs new file mode 100644 index 0000000..e047df8 --- /dev/null +++ b/agentmail-types/src/core/mod.rs @@ -0,0 +1,4 @@ +pub mod flexible_datetime; +pub mod base64_bytes; +pub mod bigint_string; +pub mod number_serializers; diff --git a/agentmail-types/src/core/number_serializers.rs b/agentmail-types/src/core/number_serializers.rs new file mode 100644 index 0000000..ad87bc5 --- /dev/null +++ b/agentmail-types/src/core/number_serializers.rs @@ -0,0 +1,173 @@ +//! Number serialization helpers +//! +//! This module provides serde helpers for serializing f64 values +//! that strips trailing `.0` from whole numbers (e.g., 24000.0 → 24000). +//! Some APIs reject the decimal representation for integer-valued numbers. +//! +//! Usage: +//! ```rust +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Serialize, Deserialize)] +//! struct MyStruct { +//! #[serde(with = "crate::core::number_serializers")] +//! sample_rate: f64, +//! } +//! ``` + +use serde::{self, Deserialize, Deserializer, Serialize, Serializer}; + +/// Serialize an f64, omitting the decimal point for whole numbers. +/// e.g., 24000.0 → 24000, 3.14 → 3.14 +pub fn serialize(value: &f64, serializer: S) -> Result +where + S: Serializer, +{ + if value.fract() == 0.0 && value.is_finite() + && *value >= (i64::MIN as f64) && *value <= (i64::MAX as f64) + { + // Serialize as integer to avoid trailing .0 + (*value as i64).serialize(serializer) + } else { + value.serialize(serializer) + } +} + +/// Deserialize an f64 (accepts both integer and float JSON values) +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + f64::deserialize(deserializer) +} + +/// Module for optional f64 fields +pub mod option { + use super::*; + + pub fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(v) => { + if v.fract() == 0.0 && v.is_finite() + && *v >= (i64::MIN as f64) && *v <= (i64::MAX as f64) + { + serializer.serialize_some(&(*v as i64)) + } else { + serializer.serialize_some(v) + } + } + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Option::::deserialize(deserializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStruct { + #[serde(with = "super")] + value: f64, + } + + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestStructOptional { + #[serde(default)] + #[serde(with = "super::option")] + #[serde(skip_serializing_if = "Option::is_none")] + value: Option, + } + + #[test] + fn test_whole_number_no_decimal() { + let test = TestStruct { value: 24000.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":24000}"#); + } + + #[test] + fn test_fractional_keeps_decimal() { + let test = TestStruct { value: 3.14 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":3.14}"#); + } + + #[test] + fn test_zero() { + let test = TestStruct { value: 0.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":0}"#); + } + + #[test] + fn test_negative_whole() { + let test = TestStruct { value: -100.0 }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":-100}"#); + } + + #[test] + fn test_deserialize_from_integer() { + let json = r#"{"value":24000}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 24000.0); + } + + #[test] + fn test_deserialize_from_float() { + let json = r#"{"value":3.14}"#; + let test: TestStruct = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, 3.14); + } + + #[test] + fn test_roundtrip() { + let original = TestStruct { value: 44100.0 }; + let json = serde_json::to_string(&original).unwrap(); + let decoded: TestStruct = serde_json::from_str(&json).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn test_optional_some_whole() { + let test = TestStructOptional { + value: Some(16000.0), + }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{"value":16000}"#); + } + + #[test] + fn test_optional_none() { + let test = TestStructOptional { value: None }; + let json = serde_json::to_string(&test).unwrap(); + assert_eq!(json, r#"{}"#); + } + + #[test] + fn test_optional_deserialize_missing() { + let json = r#"{}"#; + let test: TestStructOptional = serde_json::from_str(json).unwrap(); + assert_eq!(test.value, None); + } + + #[test] + fn test_large_whole_number_outside_i64_range() { + let test = TestStruct { value: 1e20 }; + let json = serde_json::to_string(&test).unwrap(); + // Should fall back to f64 serialization, not saturate to i64::MAX + assert_eq!(json, r#"{"value":1e+20}"#); + } +} diff --git a/agentmail-types/src/error.rs b/agentmail-types/src/error.rs new file mode 100644 index 0000000..0966ed3 --- /dev/null +++ b/agentmail-types/src/error.rs @@ -0,0 +1,19 @@ +/// Error returned when a required field was not set on a builder. +#[derive(Debug)] +pub struct BuildError { + field: &'static str, +} + +impl BuildError { + pub fn missing_field(field: &'static str) -> Self { + Self { field } + } +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "`{}` was not set but is required", self.field) + } +} + +impl std::error::Error for BuildError {} diff --git a/agentmail-types/src/lib.rs b/agentmail-types/src/lib.rs new file mode 100644 index 0000000..66586c2 --- /dev/null +++ b/agentmail-types/src/lib.rs @@ -0,0 +1,10 @@ +//! Generated models by Fern + +pub mod core; +pub mod prelude; + +pub mod error; + +pub mod types; + +pub use types::*; diff --git a/agentmail-types/src/prelude.rs b/agentmail-types/src/prelude.rs new file mode 100644 index 0000000..0f2bd99 --- /dev/null +++ b/agentmail-types/src/prelude.rs @@ -0,0 +1,7 @@ +pub use serde::{Deserialize, Serialize}; +pub use serde_json::{json, Value}; +pub use std::collections::{HashMap, HashSet}; +pub use std::fmt; +pub use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc}; +pub use ordered_float::OrderedFloat; +pub use crate::error::BuildError; diff --git a/agentmail-types/src/types/addresses.rs b/agentmail-types/src/types/addresses.rs new file mode 100644 index 0000000..a6cda13 --- /dev/null +++ b/agentmail-types/src/types/addresses.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum Addresses { + String(String), + + StringList(Vec), +} + +impl Addresses { + pub fn is_string(&self) -> bool { + matches!(self, Self::String(_)) + } + + pub fn is_string_list(&self) -> bool { + matches!(self, Self::StringList(_)) + } + + + pub fn as_string(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn into_string(self) -> Option { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn as_string_list(&self) -> Option<&Vec> { + match self { + Self::StringList(value) => Some(value), + _ => None, + } + } + + pub fn into_string_list(self) -> Option> { + match self { + Self::StringList(value) => Some(value), + _ => None, + } + } +} diff --git a/agentmail-types/src/types/after.rs b/agentmail-types/src/types/after.rs new file mode 100644 index 0000000..03c29bf --- /dev/null +++ b/agentmail-types/src/types/after.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct After( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/agent_signup_request.rs b/agentmail-types/src/types/agent_signup_request.rs new file mode 100644 index 0000000..4052a10 --- /dev/null +++ b/agentmail-types/src/types/agent_signup_request.rs @@ -0,0 +1,74 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct AgentSignupRequest { + /// Email address of the human who owns the agent. A 6-digit OTP will be sent to this address. + #[serde(default)] + pub human_email: String, + /// Username for the auto-created inbox (e.g. "my-agent" creates my-agent@agentmail.to). + #[serde(default)] + pub username: String, + /// The SDK, framework, or platform issuing this sign-up (e.g. `agentmail-python`, `agentmail-cli`, `agentmail-mcp`). + /// Identifies the caller — answers "who is signing up". + /// Max 2048 characters. + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + /// The channel that drove this sign-up — where the agent or its developer discovered AgentMail + /// (e.g. `agent.email`, a partner URL, a campaign tag). Answers "where did this sign-up come from". + /// Max 2048 characters. + #[serde(skip_serializing_if = "Option::is_none")] + pub referrer: Option, +} + +impl AgentSignupRequest { + pub fn builder() -> AgentSignupRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct AgentSignupRequestBuilder { + human_email: Option, + username: Option, + source: Option, + referrer: Option, +} + +impl AgentSignupRequestBuilder { + pub fn human_email(mut self, value: impl Into) -> Self { + self.human_email = Some(value.into()); + self + } + + pub fn username(mut self, value: impl Into) -> Self { + self.username = Some(value.into()); + self + } + + pub fn source(mut self, value: impl Into) -> Self { + self.source = Some(value.into()); + self + } + + pub fn referrer(mut self, value: impl Into) -> Self { + self.referrer = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`AgentSignupRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`human_email`](AgentSignupRequestBuilder::human_email) + /// - [`username`](AgentSignupRequestBuilder::username) + pub fn build(self) -> Result { + Ok(AgentSignupRequest { + human_email: self.human_email.ok_or_else(|| BuildError::missing_field("human_email"))?, + username: self.username.ok_or_else(|| BuildError::missing_field("username"))?, + source: self.source, + referrer: self.referrer, + }) + } +} + diff --git a/agentmail-types/src/types/agent_signup_response.rs b/agentmail-types/src/types/agent_signup_response.rs new file mode 100644 index 0000000..e8f6fe8 --- /dev/null +++ b/agentmail-types/src/types/agent_signup_response.rs @@ -0,0 +1,61 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Response after successful agent sign-up. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct AgentSignupResponse { + /// ID of the created organization. + #[serde(default)] + pub organization_id: String, + /// ID of the auto-created inbox. + #[serde(default)] + pub inbox_id: String, + /// API key for authenticating subsequent requests. Store this securely, it cannot be retrieved again. + #[serde(default)] + pub api_key: String, +} + +impl AgentSignupResponse { + pub fn builder() -> AgentSignupResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct AgentSignupResponseBuilder { + organization_id: Option, + inbox_id: Option, + api_key: Option, +} + +impl AgentSignupResponseBuilder { + pub fn organization_id(mut self, value: impl Into) -> Self { + self.organization_id = Some(value.into()); + self + } + + pub fn inbox_id(mut self, value: impl Into) -> Self { + self.inbox_id = Some(value.into()); + self + } + + pub fn api_key(mut self, value: impl Into) -> Self { + self.api_key = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`AgentSignupResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`organization_id`](AgentSignupResponseBuilder::organization_id) + /// - [`inbox_id`](AgentSignupResponseBuilder::inbox_id) + /// - [`api_key`](AgentSignupResponseBuilder::api_key) + pub fn build(self) -> Result { + Ok(AgentSignupResponse { + organization_id: self.organization_id.ok_or_else(|| BuildError::missing_field("organization_id"))?, + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + api_key: self.api_key.ok_or_else(|| BuildError::missing_field("api_key"))?, + }) + } +} diff --git a/agentmail-types/src/types/agent_verify_request.rs b/agentmail-types/src/types/agent_verify_request.rs new file mode 100644 index 0000000..f7e3802 --- /dev/null +++ b/agentmail-types/src/types/agent_verify_request.rs @@ -0,0 +1,39 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct AgentVerifyRequest { + /// 6-digit verification code sent to the human's email address. + #[serde(default)] + pub otp_code: String, +} + +impl AgentVerifyRequest { + pub fn builder() -> AgentVerifyRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct AgentVerifyRequestBuilder { + otp_code: Option, +} + +impl AgentVerifyRequestBuilder { + pub fn otp_code(mut self, value: impl Into) -> Self { + self.otp_code = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`AgentVerifyRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`otp_code`](AgentVerifyRequestBuilder::otp_code) + pub fn build(self) -> Result { + Ok(AgentVerifyRequest { + otp_code: self.otp_code.ok_or_else(|| BuildError::missing_field("otp_code"))?, + }) + } +} + diff --git a/agentmail-types/src/types/agent_verify_response.rs b/agentmail-types/src/types/agent_verify_response.rs new file mode 100644 index 0000000..cb6c423 --- /dev/null +++ b/agentmail-types/src/types/agent_verify_response.rs @@ -0,0 +1,39 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Response after successful agent verification. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct AgentVerifyResponse { + /// Whether the organization was verified. + #[serde(default)] + pub verified: bool, +} + +impl AgentVerifyResponse { + pub fn builder() -> AgentVerifyResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct AgentVerifyResponseBuilder { + verified: Option, +} + +impl AgentVerifyResponseBuilder { + pub fn verified(mut self, value: bool) -> Self { + self.verified = Some(value); + self + } + + /// Consumes the builder and constructs a [`AgentVerifyResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`verified`](AgentVerifyResponseBuilder::verified) + pub fn build(self) -> Result { + Ok(AgentVerifyResponse { + verified: self.verified.ok_or_else(|| BuildError::missing_field("verified"))?, + }) + } +} diff --git a/agentmail-types/src/types/api_key.rs b/agentmail-types/src/types/api_key.rs new file mode 100644 index 0000000..5e42b66 --- /dev/null +++ b/agentmail-types/src/types/api_key.rs @@ -0,0 +1,106 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ApiKey { + #[serde(default)] + pub api_key_id: ApiKeyId, + #[serde(default)] + pub prefix: Prefix, + #[serde(default)] + pub name: Name, + /// Pod ID the api key is scoped to. If set, the key can only access resources within this pod. + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_id: Option, + /// Inbox ID the api key is scoped to. If set, the key can only access resources within this inbox. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_id: Option, + /// Time at which api key was last used. + #[serde(skip_serializing_if = "Option::is_none")] + pub used_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + #[serde(default)] + pub created_at: CreatedAt, +} + +impl ApiKey { + pub fn builder() -> ApiKeyBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ApiKeyBuilder { + api_key_id: Option, + prefix: Option, + name: Option, + pod_id: Option, + inbox_id: Option, + used_at: Option>, + permissions: Option, + created_at: Option, +} + +impl ApiKeyBuilder { + pub fn api_key_id(mut self, value: ApiKeyId) -> Self { + self.api_key_id = Some(value); + self + } + + pub fn prefix(mut self, value: Prefix) -> Self { + self.prefix = Some(value); + self + } + + pub fn name(mut self, value: Name) -> Self { + self.name = Some(value); + self + } + + pub fn pod_id(mut self, value: impl Into) -> Self { + self.pod_id = Some(value.into()); + self + } + + pub fn inbox_id(mut self, value: impl Into) -> Self { + self.inbox_id = Some(value.into()); + self + } + + pub fn used_at(mut self, value: DateTime) -> Self { + self.used_at = Some(value); + self + } + + pub fn permissions(mut self, value: ApiKeyPermissions) -> Self { + self.permissions = Some(value); + self + } + + pub fn created_at(mut self, value: CreatedAt) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`ApiKey`]. + /// This method will fail if any of the following fields are not set: + /// - [`api_key_id`](ApiKeyBuilder::api_key_id) + /// - [`prefix`](ApiKeyBuilder::prefix) + /// - [`name`](ApiKeyBuilder::name) + /// - [`created_at`](ApiKeyBuilder::created_at) + pub fn build(self) -> Result { + Ok(ApiKey { + api_key_id: self.api_key_id.ok_or_else(|| BuildError::missing_field("api_key_id"))?, + prefix: self.prefix.ok_or_else(|| BuildError::missing_field("prefix"))?, + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + pod_id: self.pod_id, + inbox_id: self.inbox_id, + used_at: self.used_at, + permissions: self.permissions, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/api_key_id.rs b/agentmail-types/src/types/api_key_id.rs new file mode 100644 index 0000000..e74ee66 --- /dev/null +++ b/agentmail-types/src/types/api_key_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ApiKeyId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/api_key_permissions.rs b/agentmail-types/src/types/api_key_permissions.rs new file mode 100644 index 0000000..f9aa785 --- /dev/null +++ b/agentmail-types/src/types/api_key_permissions.rs @@ -0,0 +1,387 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ApiKeyPermissions { + /// Read inbox details. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_read: Option, + /// Create new inboxes. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_create: Option, + /// Update inbox settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_update: Option, + /// Delete inboxes. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_delete: Option, + /// Read messages. Also required to read threads. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_read: Option, + /// Send messages. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_send: Option, + /// Update message labels. Also required to update threads. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_update: Option, + /// Delete messages. Also required to delete threads. + #[serde(skip_serializing_if = "Option::is_none")] + pub message_delete: Option, + /// Access messages labeled spam. + #[serde(skip_serializing_if = "Option::is_none")] + pub label_spam_read: Option, + /// Access messages labeled blocked. + #[serde(skip_serializing_if = "Option::is_none")] + pub label_blocked_read: Option, + /// Access messages labeled unauthenticated. + #[serde(skip_serializing_if = "Option::is_none")] + pub label_unauthenticated_read: Option, + /// Access messages labeled trash. + #[serde(skip_serializing_if = "Option::is_none")] + pub label_trash_read: Option, + /// Read drafts. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_read: Option, + /// Create drafts. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_create: Option, + /// Update drafts. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_update: Option, + /// Delete drafts. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_delete: Option, + /// Send drafts. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_send: Option, + /// Read webhook configurations. + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_read: Option, + /// Create webhooks. + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_create: Option, + /// Update webhooks. + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_update: Option, + /// Delete webhooks. + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_delete: Option, + /// Read domain details. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_read: Option, + /// Create domains. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_create: Option, + /// Update domains. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_update: Option, + /// Delete domains. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_delete: Option, + /// Read list entries. + #[serde(skip_serializing_if = "Option::is_none")] + pub list_entry_read: Option, + /// Create list entries. + #[serde(skip_serializing_if = "Option::is_none")] + pub list_entry_create: Option, + /// Delete list entries. + #[serde(skip_serializing_if = "Option::is_none")] + pub list_entry_delete: Option, + /// Read metrics. + #[serde(skip_serializing_if = "Option::is_none")] + pub metrics_read: Option, + /// Read API keys. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_read: Option, + /// Create API keys. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_create: Option, + /// Update API keys. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_update: Option, + /// Delete API keys. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_delete: Option, + /// Read pods. + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_read: Option, + /// Create pods. + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_create: Option, + /// Delete pods. + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_delete: Option, +} + +impl ApiKeyPermissions { + pub fn builder() -> ApiKeyPermissionsBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ApiKeyPermissionsBuilder { + inbox_read: Option, + inbox_create: Option, + inbox_update: Option, + inbox_delete: Option, + message_read: Option, + message_send: Option, + message_update: Option, + message_delete: Option, + label_spam_read: Option, + label_blocked_read: Option, + label_unauthenticated_read: Option, + label_trash_read: Option, + draft_read: Option, + draft_create: Option, + draft_update: Option, + draft_delete: Option, + draft_send: Option, + webhook_read: Option, + webhook_create: Option, + webhook_update: Option, + webhook_delete: Option, + domain_read: Option, + domain_create: Option, + domain_update: Option, + domain_delete: Option, + list_entry_read: Option, + list_entry_create: Option, + list_entry_delete: Option, + metrics_read: Option, + api_key_read: Option, + api_key_create: Option, + api_key_update: Option, + api_key_delete: Option, + pod_read: Option, + pod_create: Option, + pod_delete: Option, +} + +impl ApiKeyPermissionsBuilder { + pub fn inbox_read(mut self, value: bool) -> Self { + self.inbox_read = Some(value); + self + } + + pub fn inbox_create(mut self, value: bool) -> Self { + self.inbox_create = Some(value); + self + } + + pub fn inbox_update(mut self, value: bool) -> Self { + self.inbox_update = Some(value); + self + } + + pub fn inbox_delete(mut self, value: bool) -> Self { + self.inbox_delete = Some(value); + self + } + + pub fn message_read(mut self, value: bool) -> Self { + self.message_read = Some(value); + self + } + + pub fn message_send(mut self, value: bool) -> Self { + self.message_send = Some(value); + self + } + + pub fn message_update(mut self, value: bool) -> Self { + self.message_update = Some(value); + self + } + + pub fn message_delete(mut self, value: bool) -> Self { + self.message_delete = Some(value); + self + } + + pub fn label_spam_read(mut self, value: bool) -> Self { + self.label_spam_read = Some(value); + self + } + + pub fn label_blocked_read(mut self, value: bool) -> Self { + self.label_blocked_read = Some(value); + self + } + + pub fn label_unauthenticated_read(mut self, value: bool) -> Self { + self.label_unauthenticated_read = Some(value); + self + } + + pub fn label_trash_read(mut self, value: bool) -> Self { + self.label_trash_read = Some(value); + self + } + + pub fn draft_read(mut self, value: bool) -> Self { + self.draft_read = Some(value); + self + } + + pub fn draft_create(mut self, value: bool) -> Self { + self.draft_create = Some(value); + self + } + + pub fn draft_update(mut self, value: bool) -> Self { + self.draft_update = Some(value); + self + } + + pub fn draft_delete(mut self, value: bool) -> Self { + self.draft_delete = Some(value); + self + } + + pub fn draft_send(mut self, value: bool) -> Self { + self.draft_send = Some(value); + self + } + + pub fn webhook_read(mut self, value: bool) -> Self { + self.webhook_read = Some(value); + self + } + + pub fn webhook_create(mut self, value: bool) -> Self { + self.webhook_create = Some(value); + self + } + + pub fn webhook_update(mut self, value: bool) -> Self { + self.webhook_update = Some(value); + self + } + + pub fn webhook_delete(mut self, value: bool) -> Self { + self.webhook_delete = Some(value); + self + } + + pub fn domain_read(mut self, value: bool) -> Self { + self.domain_read = Some(value); + self + } + + pub fn domain_create(mut self, value: bool) -> Self { + self.domain_create = Some(value); + self + } + + pub fn domain_update(mut self, value: bool) -> Self { + self.domain_update = Some(value); + self + } + + pub fn domain_delete(mut self, value: bool) -> Self { + self.domain_delete = Some(value); + self + } + + pub fn list_entry_read(mut self, value: bool) -> Self { + self.list_entry_read = Some(value); + self + } + + pub fn list_entry_create(mut self, value: bool) -> Self { + self.list_entry_create = Some(value); + self + } + + pub fn list_entry_delete(mut self, value: bool) -> Self { + self.list_entry_delete = Some(value); + self + } + + pub fn metrics_read(mut self, value: bool) -> Self { + self.metrics_read = Some(value); + self + } + + pub fn api_key_read(mut self, value: bool) -> Self { + self.api_key_read = Some(value); + self + } + + pub fn api_key_create(mut self, value: bool) -> Self { + self.api_key_create = Some(value); + self + } + + pub fn api_key_update(mut self, value: bool) -> Self { + self.api_key_update = Some(value); + self + } + + pub fn api_key_delete(mut self, value: bool) -> Self { + self.api_key_delete = Some(value); + self + } + + pub fn pod_read(mut self, value: bool) -> Self { + self.pod_read = Some(value); + self + } + + pub fn pod_create(mut self, value: bool) -> Self { + self.pod_create = Some(value); + self + } + + pub fn pod_delete(mut self, value: bool) -> Self { + self.pod_delete = Some(value); + self + } + + /// Consumes the builder and constructs a [`ApiKeyPermissions`]. + pub fn build(self) -> Result { + Ok(ApiKeyPermissions { + inbox_read: self.inbox_read, + inbox_create: self.inbox_create, + inbox_update: self.inbox_update, + inbox_delete: self.inbox_delete, + message_read: self.message_read, + message_send: self.message_send, + message_update: self.message_update, + message_delete: self.message_delete, + label_spam_read: self.label_spam_read, + label_blocked_read: self.label_blocked_read, + label_unauthenticated_read: self.label_unauthenticated_read, + label_trash_read: self.label_trash_read, + draft_read: self.draft_read, + draft_create: self.draft_create, + draft_update: self.draft_update, + draft_delete: self.draft_delete, + draft_send: self.draft_send, + webhook_read: self.webhook_read, + webhook_create: self.webhook_create, + webhook_update: self.webhook_update, + webhook_delete: self.webhook_delete, + domain_read: self.domain_read, + domain_create: self.domain_create, + domain_update: self.domain_update, + domain_delete: self.domain_delete, + list_entry_read: self.list_entry_read, + list_entry_create: self.list_entry_create, + list_entry_delete: self.list_entry_delete, + metrics_read: self.metrics_read, + api_key_read: self.api_key_read, + api_key_create: self.api_key_create, + api_key_update: self.api_key_update, + api_key_delete: self.api_key_delete, + pod_read: self.pod_read, + pod_create: self.pod_create, + pod_delete: self.pod_delete, + }) + } +} diff --git a/agentmail-types/src/types/api_keys_list_query_request.rs b/agentmail-types/src/types/api_keys_list_query_request.rs new file mode 100644 index 0000000..8b17c8e --- /dev/null +++ b/agentmail-types/src/types/api_keys_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ApiKeysListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl ApiKeysListQueryRequest { + pub fn builder() -> ApiKeysListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ApiKeysListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl ApiKeysListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`ApiKeysListQueryRequest`]. + pub fn build(self) -> Result { + Ok(ApiKeysListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/ascending.rs b/agentmail-types/src/types/ascending.rs new file mode 100644 index 0000000..566a610 --- /dev/null +++ b/agentmail-types/src/types/ascending.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Ascending(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/attachment.rs b/agentmail-types/src/types/attachment.rs new file mode 100644 index 0000000..0c8d0c9 --- /dev/null +++ b/agentmail-types/src/types/attachment.rs @@ -0,0 +1,83 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Attachment { + #[serde(default)] + pub attachment_id: AttachmentId, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default)] + pub size: AttachmentSize, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_disposition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option, +} + +impl Attachment { + pub fn builder() -> AttachmentBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct AttachmentBuilder { + attachment_id: Option, + filename: Option, + size: Option, + content_type: Option, + content_disposition: Option, + content_id: Option, +} + +impl AttachmentBuilder { + pub fn attachment_id(mut self, value: AttachmentId) -> Self { + self.attachment_id = Some(value); + self + } + + pub fn filename(mut self, value: AttachmentFilename) -> Self { + self.filename = Some(value); + self + } + + pub fn size(mut self, value: AttachmentSize) -> Self { + self.size = Some(value); + self + } + + pub fn content_type(mut self, value: AttachmentContentType) -> Self { + self.content_type = Some(value); + self + } + + pub fn content_disposition(mut self, value: AttachmentContentDisposition) -> Self { + self.content_disposition = Some(value); + self + } + + pub fn content_id(mut self, value: AttachmentContentId) -> Self { + self.content_id = Some(value); + self + } + + /// Consumes the builder and constructs a [`Attachment`]. + /// This method will fail if any of the following fields are not set: + /// - [`attachment_id`](AttachmentBuilder::attachment_id) + /// - [`size`](AttachmentBuilder::size) + pub fn build(self) -> Result { + Ok(Attachment { + attachment_id: self.attachment_id.ok_or_else(|| BuildError::missing_field("attachment_id"))?, + filename: self.filename, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + content_type: self.content_type, + content_disposition: self.content_disposition, + content_id: self.content_id, + }) + } +} diff --git a/agentmail-types/src/types/attachment_content_disposition.rs b/agentmail-types/src/types/attachment_content_disposition.rs new file mode 100644 index 0000000..ff955e8 --- /dev/null +++ b/agentmail-types/src/types/attachment_content_disposition.rs @@ -0,0 +1,45 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Content disposition of attachment. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AttachmentContentDisposition { + Inline, + Attachment, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for AttachmentContentDisposition { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Inline => serializer.serialize_str("inline"), + Self::Attachment => serializer.serialize_str("attachment"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for AttachmentContentDisposition { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "inline" => Ok(Self::Inline), + "attachment" => Ok(Self::Attachment), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for AttachmentContentDisposition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Inline => write!(f, "inline"), + Self::Attachment => write!(f, "attachment"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/attachment_content_id.rs b/agentmail-types/src/types/attachment_content_id.rs new file mode 100644 index 0000000..bf098ff --- /dev/null +++ b/agentmail-types/src/types/attachment_content_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct AttachmentContentId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/attachment_content_type.rs b/agentmail-types/src/types/attachment_content_type.rs new file mode 100644 index 0000000..c070e0c --- /dev/null +++ b/agentmail-types/src/types/attachment_content_type.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct AttachmentContentType(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/attachment_filename.rs b/agentmail-types/src/types/attachment_filename.rs new file mode 100644 index 0000000..ba138cd --- /dev/null +++ b/agentmail-types/src/types/attachment_filename.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct AttachmentFilename(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/attachment_id.rs b/agentmail-types/src/types/attachment_id.rs new file mode 100644 index 0000000..2276e71 --- /dev/null +++ b/agentmail-types/src/types/attachment_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct AttachmentId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/attachment_response.rs b/agentmail-types/src/types/attachment_response.rs new file mode 100644 index 0000000..97a2269 --- /dev/null +++ b/agentmail-types/src/types/attachment_response.rs @@ -0,0 +1,106 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct AttachmentResponse { + #[serde(default)] + pub attachment_id: AttachmentId, + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default)] + pub size: AttachmentSize, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_disposition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option, + /// URL to download the attachment. + #[serde(default)] + pub download_url: String, + /// Time at which the download URL expires. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub expires_at: DateTime, +} + +impl AttachmentResponse { + pub fn builder() -> AttachmentResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct AttachmentResponseBuilder { + attachment_id: Option, + filename: Option, + size: Option, + content_type: Option, + content_disposition: Option, + content_id: Option, + download_url: Option, + expires_at: Option>, +} + +impl AttachmentResponseBuilder { + pub fn attachment_id(mut self, value: AttachmentId) -> Self { + self.attachment_id = Some(value); + self + } + + pub fn filename(mut self, value: AttachmentFilename) -> Self { + self.filename = Some(value); + self + } + + pub fn size(mut self, value: AttachmentSize) -> Self { + self.size = Some(value); + self + } + + pub fn content_type(mut self, value: AttachmentContentType) -> Self { + self.content_type = Some(value); + self + } + + pub fn content_disposition(mut self, value: AttachmentContentDisposition) -> Self { + self.content_disposition = Some(value); + self + } + + pub fn content_id(mut self, value: AttachmentContentId) -> Self { + self.content_id = Some(value); + self + } + + pub fn download_url(mut self, value: impl Into) -> Self { + self.download_url = Some(value.into()); + self + } + + pub fn expires_at(mut self, value: DateTime) -> Self { + self.expires_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`AttachmentResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`attachment_id`](AttachmentResponseBuilder::attachment_id) + /// - [`size`](AttachmentResponseBuilder::size) + /// - [`download_url`](AttachmentResponseBuilder::download_url) + /// - [`expires_at`](AttachmentResponseBuilder::expires_at) + pub fn build(self) -> Result { + Ok(AttachmentResponse { + attachment_id: self.attachment_id.ok_or_else(|| BuildError::missing_field("attachment_id"))?, + filename: self.filename, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + content_type: self.content_type, + content_disposition: self.content_disposition, + content_id: self.content_id, + download_url: self.download_url.ok_or_else(|| BuildError::missing_field("download_url"))?, + expires_at: self.expires_at.ok_or_else(|| BuildError::missing_field("expires_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/attachment_size.rs b/agentmail-types/src/types/attachment_size.rs new file mode 100644 index 0000000..6b4ba53 --- /dev/null +++ b/agentmail-types/src/types/attachment_size.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct AttachmentSize(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/batch_get_messages_message_ids.rs b/agentmail-types/src/types/batch_get_messages_message_ids.rs new file mode 100644 index 0000000..74c1888 --- /dev/null +++ b/agentmail-types/src/types/batch_get_messages_message_ids.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct BatchGetMessagesMessageIds(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/batch_get_messages_request.rs b/agentmail-types/src/types/batch_get_messages_request.rs new file mode 100644 index 0000000..48271da --- /dev/null +++ b/agentmail-types/src/types/batch_get_messages_request.rs @@ -0,0 +1,38 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct BatchGetMessagesRequest { + #[serde(default)] + pub message_ids: BatchGetMessagesMessageIds, +} + +impl BatchGetMessagesRequest { + pub fn builder() -> BatchGetMessagesRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct BatchGetMessagesRequestBuilder { + message_ids: Option, +} + +impl BatchGetMessagesRequestBuilder { + pub fn message_ids(mut self, value: BatchGetMessagesMessageIds) -> Self { + self.message_ids = Some(value); + self + } + + /// Consumes the builder and constructs a [`BatchGetMessagesRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`message_ids`](BatchGetMessagesRequestBuilder::message_ids) + pub fn build(self) -> Result { + Ok(BatchGetMessagesRequest { + message_ids: self.message_ids.ok_or_else(|| BuildError::missing_field("message_ids"))?, + }) + } +} + diff --git a/agentmail-types/src/types/batch_get_messages_response.rs b/agentmail-types/src/types/batch_get_messages_response.rs new file mode 100644 index 0000000..77ec845 --- /dev/null +++ b/agentmail-types/src/types/batch_get_messages_response.rs @@ -0,0 +1,60 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct BatchGetMessagesResponse { + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub count: Count, + /// Found messages. Order matches `message_ids` in the request. Body + /// fields (`text`, `html`, `extracted_text`, `extracted_html`) are + /// never populated; use the single-message endpoint to retrieve bodies. + #[serde(default)] + pub messages: Vec, +} + +impl BatchGetMessagesResponse { + pub fn builder() -> BatchGetMessagesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct BatchGetMessagesResponseBuilder { + limit: Option, + count: Option, + messages: Option>, +} + +impl BatchGetMessagesResponseBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn messages(mut self, value: Vec) -> Self { + self.messages = Some(value); + self + } + + /// Consumes the builder and constructs a [`BatchGetMessagesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`limit`](BatchGetMessagesResponseBuilder::limit) + /// - [`count`](BatchGetMessagesResponseBuilder::count) + /// - [`messages`](BatchGetMessagesResponseBuilder::messages) + pub fn build(self) -> Result { + Ok(BatchGetMessagesResponse { + limit: self.limit.ok_or_else(|| BuildError::missing_field("limit"))?, + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + messages: self.messages.ok_or_else(|| BuildError::missing_field("messages"))?, + }) + } +} diff --git a/agentmail-types/src/types/batch_update_messages_message_ids.rs b/agentmail-types/src/types/batch_update_messages_message_ids.rs new file mode 100644 index 0000000..fee96f0 --- /dev/null +++ b/agentmail-types/src/types/batch_update_messages_message_ids.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct BatchUpdateMessagesMessageIds(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/batch_update_messages_request.rs b/agentmail-types/src/types/batch_update_messages_request.rs new file mode 100644 index 0000000..7a9af27 --- /dev/null +++ b/agentmail-types/src/types/batch_update_messages_request.rs @@ -0,0 +1,58 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct BatchUpdateMessagesRequest { + #[serde(default)] + pub message_ids: BatchUpdateMessagesMessageIds, + /// Label or labels to add to every message. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_labels: Option, + /// Label or labels to remove from every message. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_labels: Option, +} + +impl BatchUpdateMessagesRequest { + pub fn builder() -> BatchUpdateMessagesRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct BatchUpdateMessagesRequestBuilder { + message_ids: Option, + add_labels: Option, + remove_labels: Option, +} + +impl BatchUpdateMessagesRequestBuilder { + pub fn message_ids(mut self, value: BatchUpdateMessagesMessageIds) -> Self { + self.message_ids = Some(value); + self + } + + pub fn add_labels(mut self, value: UpdateMessageLabels) -> Self { + self.add_labels = Some(value); + self + } + + pub fn remove_labels(mut self, value: UpdateMessageLabels) -> Self { + self.remove_labels = Some(value); + self + } + + /// Consumes the builder and constructs a [`BatchUpdateMessagesRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`message_ids`](BatchUpdateMessagesRequestBuilder::message_ids) + pub fn build(self) -> Result { + Ok(BatchUpdateMessagesRequest { + message_ids: self.message_ids.ok_or_else(|| BuildError::missing_field("message_ids"))?, + add_labels: self.add_labels, + remove_labels: self.remove_labels, + }) + } +} + diff --git a/agentmail-types/src/types/batch_update_messages_response.rs b/agentmail-types/src/types/batch_update_messages_response.rs new file mode 100644 index 0000000..8d28964 --- /dev/null +++ b/agentmail-types/src/types/batch_update_messages_response.rs @@ -0,0 +1,60 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct BatchUpdateMessagesResponse { + #[serde(default)] + pub limit: Limit, + #[serde(default)] + pub count: Count, + /// Updated messages with their new labels. Order matches `message_ids` + /// in the request. Excluded ids are omitted, so `count` may be less than + /// `limit`. + #[serde(default)] + pub updates: Vec, +} + +impl BatchUpdateMessagesResponse { + pub fn builder() -> BatchUpdateMessagesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct BatchUpdateMessagesResponseBuilder { + limit: Option, + count: Option, + updates: Option>, +} + +impl BatchUpdateMessagesResponseBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn updates(mut self, value: Vec) -> Self { + self.updates = Some(value); + self + } + + /// Consumes the builder and constructs a [`BatchUpdateMessagesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`limit`](BatchUpdateMessagesResponseBuilder::limit) + /// - [`count`](BatchUpdateMessagesResponseBuilder::count) + /// - [`updates`](BatchUpdateMessagesResponseBuilder::updates) + pub fn build(self) -> Result { + Ok(BatchUpdateMessagesResponse { + limit: self.limit.ok_or_else(|| BuildError::missing_field("limit"))?, + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + updates: self.updates.ok_or_else(|| BuildError::missing_field("updates"))?, + }) + } +} diff --git a/agentmail-types/src/types/before.rs b/agentmail-types/src/types/before.rs new file mode 100644 index 0000000..b8cbbe6 --- /dev/null +++ b/agentmail-types/src/types/before.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Before( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/bounce.rs b/agentmail-types/src/types/bounce.rs new file mode 100644 index 0000000..f33e477 --- /dev/null +++ b/agentmail-types/src/types/bounce.rs @@ -0,0 +1,100 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Bounce { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub timestamp: Timestamp, + /// Bounce type. + #[serde(default)] + pub r#type: String, + /// Bounce sub-type. + #[serde(default)] + pub sub_type: String, + /// Bounced recipients. + #[serde(default)] + pub recipients: Vec, +} + +impl Bounce { + pub fn builder() -> BounceBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct BounceBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + timestamp: Option, + r#type: Option, + sub_type: Option, + recipients: Option>, +} + +impl BounceBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn timestamp(mut self, value: Timestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn r#type(mut self, value: impl Into) -> Self { + self.r#type = Some(value.into()); + self + } + + pub fn sub_type(mut self, value: impl Into) -> Self { + self.sub_type = Some(value.into()); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + /// Consumes the builder and constructs a [`Bounce`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](BounceBuilder::inbox_id) + /// - [`thread_id`](BounceBuilder::thread_id) + /// - [`message_id`](BounceBuilder::message_id) + /// - [`timestamp`](BounceBuilder::timestamp) + /// - [`r#type`](BounceBuilder::r#type) + /// - [`sub_type`](BounceBuilder::sub_type) + /// - [`recipients`](BounceBuilder::recipients) + pub fn build(self) -> Result { + Ok(Bounce { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + sub_type: self.sub_type.ok_or_else(|| BuildError::missing_field("sub_type"))?, + recipients: self.recipients.ok_or_else(|| BuildError::missing_field("recipients"))?, + }) + } +} diff --git a/agentmail-types/src/types/client_id.rs b/agentmail-types/src/types/client_id.rs new file mode 100644 index 0000000..dce3eac --- /dev/null +++ b/agentmail-types/src/types/client_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ClientId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/complaint.rs b/agentmail-types/src/types/complaint.rs new file mode 100644 index 0000000..d8211ce --- /dev/null +++ b/agentmail-types/src/types/complaint.rs @@ -0,0 +1,100 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Complaint { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub timestamp: Timestamp, + /// Complaint type. + #[serde(default)] + pub r#type: String, + /// Complaint sub-type. + #[serde(default)] + pub sub_type: String, + /// Complained recipients. + #[serde(default)] + pub recipients: Vec, +} + +impl Complaint { + pub fn builder() -> ComplaintBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ComplaintBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + timestamp: Option, + r#type: Option, + sub_type: Option, + recipients: Option>, +} + +impl ComplaintBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn timestamp(mut self, value: Timestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn r#type(mut self, value: impl Into) -> Self { + self.r#type = Some(value.into()); + self + } + + pub fn sub_type(mut self, value: impl Into) -> Self { + self.sub_type = Some(value.into()); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + /// Consumes the builder and constructs a [`Complaint`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](ComplaintBuilder::inbox_id) + /// - [`thread_id`](ComplaintBuilder::thread_id) + /// - [`message_id`](ComplaintBuilder::message_id) + /// - [`timestamp`](ComplaintBuilder::timestamp) + /// - [`r#type`](ComplaintBuilder::r#type) + /// - [`sub_type`](ComplaintBuilder::sub_type) + /// - [`recipients`](ComplaintBuilder::recipients) + pub fn build(self) -> Result { + Ok(Complaint { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + sub_type: self.sub_type.ok_or_else(|| BuildError::missing_field("sub_type"))?, + recipients: self.recipients.ok_or_else(|| BuildError::missing_field("recipients"))?, + }) + } +} diff --git a/agentmail-types/src/types/count.rs b/agentmail-types/src/types/count.rs new file mode 100644 index 0000000..4b61538 --- /dev/null +++ b/agentmail-types/src/types/count.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Count(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/create_api_key_request.rs b/agentmail-types/src/types/create_api_key_request.rs new file mode 100644 index 0000000..a2c6b83 --- /dev/null +++ b/agentmail-types/src/types/create_api_key_request.rs @@ -0,0 +1,44 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct CreateApiKeyRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, +} + +impl CreateApiKeyRequest { + pub fn builder() -> CreateApiKeyRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct CreateApiKeyRequestBuilder { + name: Option, + permissions: Option, +} + +impl CreateApiKeyRequestBuilder { + pub fn name(mut self, value: Name) -> Self { + self.name = Some(value); + self + } + + pub fn permissions(mut self, value: ApiKeyPermissions) -> Self { + self.permissions = Some(value); + self + } + + /// Consumes the builder and constructs a [`CreateApiKeyRequest`]. + pub fn build(self) -> Result { + Ok(CreateApiKeyRequest { + name: self.name, + permissions: self.permissions, + }) + } +} diff --git a/agentmail-types/src/types/create_api_key_response.rs b/agentmail-types/src/types/create_api_key_response.rs new file mode 100644 index 0000000..7d8a07b --- /dev/null +++ b/agentmail-types/src/types/create_api_key_response.rs @@ -0,0 +1,107 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct CreateApiKeyResponse { + #[serde(default)] + pub api_key_id: ApiKeyId, + /// API key. + #[serde(default)] + pub api_key: String, + #[serde(default)] + pub prefix: Prefix, + #[serde(default)] + pub name: Name, + /// Pod ID the api key is scoped to. + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_id: Option, + /// Inbox ID the api key is scoped to. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option, + #[serde(default)] + pub created_at: CreatedAt, +} + +impl CreateApiKeyResponse { + pub fn builder() -> CreateApiKeyResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct CreateApiKeyResponseBuilder { + api_key_id: Option, + api_key: Option, + prefix: Option, + name: Option, + pod_id: Option, + inbox_id: Option, + permissions: Option, + created_at: Option, +} + +impl CreateApiKeyResponseBuilder { + pub fn api_key_id(mut self, value: ApiKeyId) -> Self { + self.api_key_id = Some(value); + self + } + + pub fn api_key(mut self, value: impl Into) -> Self { + self.api_key = Some(value.into()); + self + } + + pub fn prefix(mut self, value: Prefix) -> Self { + self.prefix = Some(value); + self + } + + pub fn name(mut self, value: Name) -> Self { + self.name = Some(value); + self + } + + pub fn pod_id(mut self, value: impl Into) -> Self { + self.pod_id = Some(value.into()); + self + } + + pub fn inbox_id(mut self, value: impl Into) -> Self { + self.inbox_id = Some(value.into()); + self + } + + pub fn permissions(mut self, value: ApiKeyPermissions) -> Self { + self.permissions = Some(value); + self + } + + pub fn created_at(mut self, value: CreatedAt) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`CreateApiKeyResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`api_key_id`](CreateApiKeyResponseBuilder::api_key_id) + /// - [`api_key`](CreateApiKeyResponseBuilder::api_key) + /// - [`prefix`](CreateApiKeyResponseBuilder::prefix) + /// - [`name`](CreateApiKeyResponseBuilder::name) + /// - [`created_at`](CreateApiKeyResponseBuilder::created_at) + pub fn build(self) -> Result { + Ok(CreateApiKeyResponse { + api_key_id: self.api_key_id.ok_or_else(|| BuildError::missing_field("api_key_id"))?, + api_key: self.api_key.ok_or_else(|| BuildError::missing_field("api_key"))?, + prefix: self.prefix.ok_or_else(|| BuildError::missing_field("prefix"))?, + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + pod_id: self.pod_id, + inbox_id: self.inbox_id, + permissions: self.permissions, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/create_domain_request.rs b/agentmail-types/src/types/create_domain_request.rs new file mode 100644 index 0000000..cb44363 --- /dev/null +++ b/agentmail-types/src/types/create_domain_request.rs @@ -0,0 +1,64 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct CreateDomainRequest { + #[serde(default)] + pub domain: DomainName, + #[serde(skip_serializing_if = "Option::is_none")] + pub feedback_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subdomains_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tracking_enabled: Option, +} + +impl CreateDomainRequest { + pub fn builder() -> CreateDomainRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct CreateDomainRequestBuilder { + domain: Option, + feedback_enabled: Option, + subdomains_enabled: Option, + tracking_enabled: Option, +} + +impl CreateDomainRequestBuilder { + pub fn domain(mut self, value: DomainName) -> Self { + self.domain = Some(value); + self + } + + pub fn feedback_enabled(mut self, value: FeedbackEnabled) -> Self { + self.feedback_enabled = Some(value); + self + } + + pub fn subdomains_enabled(mut self, value: SubdomainsEnabled) -> Self { + self.subdomains_enabled = Some(value); + self + } + + pub fn tracking_enabled(mut self, value: TrackingEnabled) -> Self { + self.tracking_enabled = Some(value); + self + } + + /// Consumes the builder and constructs a [`CreateDomainRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`domain`](CreateDomainRequestBuilder::domain) + pub fn build(self) -> Result { + Ok(CreateDomainRequest { + domain: self.domain.ok_or_else(|| BuildError::missing_field("domain"))?, + feedback_enabled: self.feedback_enabled, + subdomains_enabled: self.subdomains_enabled, + tracking_enabled: self.tracking_enabled, + }) + } +} diff --git a/agentmail-types/src/types/create_draft_request.rs b/agentmail-types/src/types/create_draft_request.rs new file mode 100644 index 0000000..79cad73 --- /dev/null +++ b/agentmail-types/src/types/create_draft_request.rs @@ -0,0 +1,154 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct CreateDraftRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + /// Attachments to include in draft. + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forward_of: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_all: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub send_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +impl CreateDraftRequest { + pub fn builder() -> CreateDraftRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct CreateDraftRequestBuilder { + labels: Option, + reply_to: Option, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + text: Option, + html: Option, + attachments: Option>, + in_reply_to: Option, + forward_of: Option, + reply_all: Option, + send_at: Option, + client_id: Option, +} + +impl CreateDraftRequestBuilder { + pub fn labels(mut self, value: DraftLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn reply_to(mut self, value: DraftReplyTo) -> Self { + self.reply_to = Some(value); + self + } + + pub fn to(mut self, value: DraftTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: DraftCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: DraftBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: DraftSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn text(mut self, value: DraftText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: DraftHtml) -> Self { + self.html = Some(value); + self + } + + pub fn attachments(mut self, value: Vec) -> Self { + self.attachments = Some(value); + self + } + + pub fn in_reply_to(mut self, value: DraftInReplyTo) -> Self { + self.in_reply_to = Some(value); + self + } + + pub fn forward_of(mut self, value: DraftForwardOf) -> Self { + self.forward_of = Some(value); + self + } + + pub fn reply_all(mut self, value: DraftReplyAll) -> Self { + self.reply_all = Some(value); + self + } + + pub fn send_at(mut self, value: DraftSendAt) -> Self { + self.send_at = Some(value); + self + } + + pub fn client_id(mut self, value: DraftClientId) -> Self { + self.client_id = Some(value); + self + } + + /// Consumes the builder and constructs a [`CreateDraftRequest`]. + pub fn build(self) -> Result { + Ok(CreateDraftRequest { + labels: self.labels, + reply_to: self.reply_to, + to: self.to, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + text: self.text, + html: self.html, + attachments: self.attachments, + in_reply_to: self.in_reply_to, + forward_of: self.forward_of, + reply_all: self.reply_all, + send_at: self.send_at, + client_id: self.client_id, + }) + } +} + diff --git a/agentmail-types/src/types/create_list_entry_request.rs b/agentmail-types/src/types/create_list_entry_request.rs new file mode 100644 index 0000000..690d07e --- /dev/null +++ b/agentmail-types/src/types/create_list_entry_request.rs @@ -0,0 +1,48 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct CreateListEntryRequest { + /// Email address or domain to add. + #[serde(default)] + pub entry: String, + /// Reason for adding the entry. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl CreateListEntryRequest { + pub fn builder() -> CreateListEntryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct CreateListEntryRequestBuilder { + entry: Option, + reason: Option, +} + +impl CreateListEntryRequestBuilder { + pub fn entry(mut self, value: impl Into) -> Self { + self.entry = Some(value.into()); + self + } + + pub fn reason(mut self, value: impl Into) -> Self { + self.reason = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`CreateListEntryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`entry`](CreateListEntryRequestBuilder::entry) + pub fn build(self) -> Result { + Ok(CreateListEntryRequest { + entry: self.entry.ok_or_else(|| BuildError::missing_field("entry"))?, + reason: self.reason, + }) + } +} diff --git a/agentmail-types/src/types/create_public_key_request.rs b/agentmail-types/src/types/create_public_key_request.rs new file mode 100644 index 0000000..371d859 --- /dev/null +++ b/agentmail-types/src/types/create_public_key_request.rs @@ -0,0 +1,69 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct CreatePublicKeyRequest { + pub public_key: PublicJwk, + /// Defaults to `AgentID key {first eight fingerprint characters}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Omit to inherit the registering bearer key's exact scope. An explicit + /// scope must be the caller's scope or a live descendant. + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + /// Future absolute expiry. Omit to inherit the registering bearer key's + /// expiry. A child credential cannot outlive its creator. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, +} + +impl CreatePublicKeyRequest { + pub fn builder() -> CreatePublicKeyRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct CreatePublicKeyRequestBuilder { + public_key: Option, + name: Option, + scope: Option, + expires_at: Option>, +} + +impl CreatePublicKeyRequestBuilder { + pub fn public_key(mut self, value: PublicJwk) -> Self { + self.public_key = Some(value); + self + } + + pub fn name(mut self, value: impl Into) -> Self { + self.name = Some(value.into()); + self + } + + pub fn scope(mut self, value: PublicKeyScope) -> Self { + self.scope = Some(value); + self + } + + pub fn expires_at(mut self, value: DateTime) -> Self { + self.expires_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`CreatePublicKeyRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`public_key`](CreatePublicKeyRequestBuilder::public_key) + pub fn build(self) -> Result { + Ok(CreatePublicKeyRequest { + public_key: self.public_key.ok_or_else(|| BuildError::missing_field("public_key"))?, + name: self.name, + scope: self.scope, + expires_at: self.expires_at, + }) + } +} + diff --git a/agentmail-types/src/types/created_at.rs b/agentmail-types/src/types/created_at.rs new file mode 100644 index 0000000..60cb3f7 --- /dev/null +++ b/agentmail-types/src/types/created_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct CreatedAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/delivery.rs b/agentmail-types/src/types/delivery.rs new file mode 100644 index 0000000..78967fc --- /dev/null +++ b/agentmail-types/src/types/delivery.rs @@ -0,0 +1,78 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Delivery { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub timestamp: Timestamp, + /// Delivered recipients. + #[serde(default)] + pub recipients: Vec, +} + +impl Delivery { + pub fn builder() -> DeliveryBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DeliveryBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + timestamp: Option, + recipients: Option>, +} + +impl DeliveryBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn timestamp(mut self, value: Timestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + /// Consumes the builder and constructs a [`Delivery`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](DeliveryBuilder::inbox_id) + /// - [`thread_id`](DeliveryBuilder::thread_id) + /// - [`message_id`](DeliveryBuilder::message_id) + /// - [`timestamp`](DeliveryBuilder::timestamp) + /// - [`recipients`](DeliveryBuilder::recipients) + pub fn build(self) -> Result { + Ok(Delivery { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + recipients: self.recipients.ok_or_else(|| BuildError::missing_field("recipients"))?, + }) + } +} diff --git a/agentmail-types/src/types/descending.rs b/agentmail-types/src/types/descending.rs new file mode 100644 index 0000000..fbbe67b --- /dev/null +++ b/agentmail-types/src/types/descending.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Descending(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/direction.rs b/agentmail-types/src/types/direction.rs new file mode 100644 index 0000000..3ed8868 --- /dev/null +++ b/agentmail-types/src/types/direction.rs @@ -0,0 +1,49 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Direction of list entry. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Direction { + Send, + Receive, + Reply, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for Direction { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Send => serializer.serialize_str("send"), + Self::Receive => serializer.serialize_str("receive"), + Self::Reply => serializer.serialize_str("reply"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for Direction { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "send" => Ok(Self::Send), + "receive" => Ok(Self::Receive), + "reply" => Ok(Self::Reply), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for Direction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Send => write!(f, "send"), + Self::Receive => write!(f, "receive"), + Self::Reply => write!(f, "reply"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/domain.rs b/agentmail-types/src/types/domain.rs new file mode 100644 index 0000000..426a589 --- /dev/null +++ b/agentmail-types/src/types/domain.rs @@ -0,0 +1,140 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Domain { + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_id: Option, + #[serde(default)] + pub domain_id: DomainId, + #[serde(default)] + pub domain: DomainName, + pub status: Status, + #[serde(default)] + pub feedback_enabled: FeedbackEnabled, + #[serde(default)] + pub subdomains_enabled: SubdomainsEnabled, + #[serde(default)] + pub tracking_enabled: TrackingEnabled, + /// A list of DNS records required to verify the domain. Includes a + /// wildcard MX record (`*.`) when `subdomains_enabled` is true. + #[serde(default)] + pub records: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + /// Time at which the domain was last updated. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, + /// Time at which the domain was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, +} + +impl Domain { + pub fn builder() -> DomainBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DomainBuilder { + pod_id: Option, + domain_id: Option, + domain: Option, + status: Option, + feedback_enabled: Option, + subdomains_enabled: Option, + tracking_enabled: Option, + records: Option>, + client_id: Option, + updated_at: Option>, + created_at: Option>, +} + +impl DomainBuilder { + pub fn pod_id(mut self, value: PodsPodId) -> Self { + self.pod_id = Some(value); + self + } + + pub fn domain_id(mut self, value: DomainId) -> Self { + self.domain_id = Some(value); + self + } + + pub fn domain(mut self, value: DomainName) -> Self { + self.domain = Some(value); + self + } + + pub fn status(mut self, value: Status) -> Self { + self.status = Some(value); + self + } + + pub fn feedback_enabled(mut self, value: FeedbackEnabled) -> Self { + self.feedback_enabled = Some(value); + self + } + + pub fn subdomains_enabled(mut self, value: SubdomainsEnabled) -> Self { + self.subdomains_enabled = Some(value); + self + } + + pub fn tracking_enabled(mut self, value: TrackingEnabled) -> Self { + self.tracking_enabled = Some(value); + self + } + + pub fn records(mut self, value: Vec) -> Self { + self.records = Some(value); + self + } + + pub fn client_id(mut self, value: ClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`Domain`]. + /// This method will fail if any of the following fields are not set: + /// - [`domain_id`](DomainBuilder::domain_id) + /// - [`domain`](DomainBuilder::domain) + /// - [`status`](DomainBuilder::status) + /// - [`feedback_enabled`](DomainBuilder::feedback_enabled) + /// - [`subdomains_enabled`](DomainBuilder::subdomains_enabled) + /// - [`tracking_enabled`](DomainBuilder::tracking_enabled) + /// - [`records`](DomainBuilder::records) + /// - [`updated_at`](DomainBuilder::updated_at) + /// - [`created_at`](DomainBuilder::created_at) + pub fn build(self) -> Result { + Ok(Domain { + pod_id: self.pod_id, + domain_id: self.domain_id.ok_or_else(|| BuildError::missing_field("domain_id"))?, + domain: self.domain.ok_or_else(|| BuildError::missing_field("domain"))?, + status: self.status.ok_or_else(|| BuildError::missing_field("status"))?, + feedback_enabled: self.feedback_enabled.ok_or_else(|| BuildError::missing_field("feedback_enabled"))?, + subdomains_enabled: self.subdomains_enabled.ok_or_else(|| BuildError::missing_field("subdomains_enabled"))?, + tracking_enabled: self.tracking_enabled.ok_or_else(|| BuildError::missing_field("tracking_enabled"))?, + records: self.records.ok_or_else(|| BuildError::missing_field("records"))?, + client_id: self.client_id, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/domain_id.rs b/agentmail-types/src/types/domain_id.rs new file mode 100644 index 0000000..129d4a3 --- /dev/null +++ b/agentmail-types/src/types/domain_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DomainId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/domain_item.rs b/agentmail-types/src/types/domain_item.rs new file mode 100644 index 0000000..a27b284 --- /dev/null +++ b/agentmail-types/src/types/domain_item.rs @@ -0,0 +1,119 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct DomainItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_id: Option, + #[serde(default)] + pub domain_id: DomainId, + #[serde(default)] + pub domain: DomainName, + #[serde(default)] + pub feedback_enabled: FeedbackEnabled, + #[serde(default)] + pub subdomains_enabled: SubdomainsEnabled, + #[serde(default)] + pub tracking_enabled: TrackingEnabled, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + /// Time at which the domain was last updated. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, + /// Time at which the domain was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, +} + +impl DomainItem { + pub fn builder() -> DomainItemBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DomainItemBuilder { + pod_id: Option, + domain_id: Option, + domain: Option, + feedback_enabled: Option, + subdomains_enabled: Option, + tracking_enabled: Option, + client_id: Option, + updated_at: Option>, + created_at: Option>, +} + +impl DomainItemBuilder { + pub fn pod_id(mut self, value: PodsPodId) -> Self { + self.pod_id = Some(value); + self + } + + pub fn domain_id(mut self, value: DomainId) -> Self { + self.domain_id = Some(value); + self + } + + pub fn domain(mut self, value: DomainName) -> Self { + self.domain = Some(value); + self + } + + pub fn feedback_enabled(mut self, value: FeedbackEnabled) -> Self { + self.feedback_enabled = Some(value); + self + } + + pub fn subdomains_enabled(mut self, value: SubdomainsEnabled) -> Self { + self.subdomains_enabled = Some(value); + self + } + + pub fn tracking_enabled(mut self, value: TrackingEnabled) -> Self { + self.tracking_enabled = Some(value); + self + } + + pub fn client_id(mut self, value: ClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`DomainItem`]. + /// This method will fail if any of the following fields are not set: + /// - [`domain_id`](DomainItemBuilder::domain_id) + /// - [`domain`](DomainItemBuilder::domain) + /// - [`feedback_enabled`](DomainItemBuilder::feedback_enabled) + /// - [`subdomains_enabled`](DomainItemBuilder::subdomains_enabled) + /// - [`tracking_enabled`](DomainItemBuilder::tracking_enabled) + /// - [`updated_at`](DomainItemBuilder::updated_at) + /// - [`created_at`](DomainItemBuilder::created_at) + pub fn build(self) -> Result { + Ok(DomainItem { + pod_id: self.pod_id, + domain_id: self.domain_id.ok_or_else(|| BuildError::missing_field("domain_id"))?, + domain: self.domain.ok_or_else(|| BuildError::missing_field("domain"))?, + feedback_enabled: self.feedback_enabled.ok_or_else(|| BuildError::missing_field("feedback_enabled"))?, + subdomains_enabled: self.subdomains_enabled.ok_or_else(|| BuildError::missing_field("subdomains_enabled"))?, + tracking_enabled: self.tracking_enabled.ok_or_else(|| BuildError::missing_field("tracking_enabled"))?, + client_id: self.client_id, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/domain_name.rs b/agentmail-types/src/types/domain_name.rs new file mode 100644 index 0000000..9d7081d --- /dev/null +++ b/agentmail-types/src/types/domain_name.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DomainName(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/domain_verified_event.rs b/agentmail-types/src/types/domain_verified_event.rs new file mode 100644 index 0000000..89b20b0 --- /dev/null +++ b/agentmail-types/src/types/domain_verified_event.rs @@ -0,0 +1,64 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct DomainVerifiedEvent { + pub r#type: DomainVerifiedEventType, + pub event_type: DomainVerifiedEventEventType, + #[serde(default)] + pub event_id: EventId, + pub domain: Domain, +} + +impl DomainVerifiedEvent { + pub fn builder() -> DomainVerifiedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DomainVerifiedEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + domain: Option, +} + +impl DomainVerifiedEventBuilder { + pub fn r#type(mut self, value: DomainVerifiedEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: DomainVerifiedEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn domain(mut self, value: Domain) -> Self { + self.domain = Some(value); + self + } + + /// Consumes the builder and constructs a [`DomainVerifiedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](DomainVerifiedEventBuilder::r#type) + /// - [`event_type`](DomainVerifiedEventBuilder::event_type) + /// - [`event_id`](DomainVerifiedEventBuilder::event_id) + /// - [`domain`](DomainVerifiedEventBuilder::domain) + pub fn build(self) -> Result { + Ok(DomainVerifiedEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + domain: self.domain.ok_or_else(|| BuildError::missing_field("domain"))?, + }) + } +} diff --git a/agentmail-types/src/types/domain_verified_event_event_type.rs b/agentmail-types/src/types/domain_verified_event_event_type.rs new file mode 100644 index 0000000..e4ab080 --- /dev/null +++ b/agentmail-types/src/types/domain_verified_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum DomainVerifiedEventEventType { + #[serde(rename = "domain.verified")] + DomainVerified, +} +impl fmt::Display for DomainVerifiedEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::DomainVerified => "domain.verified", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/domain_verified_event_type.rs b/agentmail-types/src/types/domain_verified_event_type.rs new file mode 100644 index 0000000..525eb3d --- /dev/null +++ b/agentmail-types/src/types/domain_verified_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum DomainVerifiedEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for DomainVerifiedEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/domains_list_query_request.rs b/agentmail-types/src/types/domains_list_query_request.rs new file mode 100644 index 0000000..1b119a0 --- /dev/null +++ b/agentmail-types/src/types/domains_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct DomainsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl DomainsListQueryRequest { + pub fn builder() -> DomainsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DomainsListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl DomainsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`DomainsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(DomainsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/draft.rs b/agentmail-types/src/types/draft.rs new file mode 100644 index 0000000..d7329ef --- /dev/null +++ b/agentmail-types/src/types/draft.rs @@ -0,0 +1,215 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Draft { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub draft_id: DraftId, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(default)] + pub labels: DraftLabels, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forward_of: Option, + /// IDs of previous messages in thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub references: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub send_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub send_at: Option, + #[serde(default)] + pub updated_at: DraftUpdatedAt, + /// Time at which draft was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, +} + +impl Draft { + pub fn builder() -> DraftBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DraftBuilder { + inbox_id: Option, + draft_id: Option, + client_id: Option, + labels: Option, + reply_to: Option, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + preview: Option, + text: Option, + html: Option, + attachments: Option, + in_reply_to: Option, + forward_of: Option, + references: Option>, + send_status: Option, + send_at: Option, + updated_at: Option, + created_at: Option>, +} + +impl DraftBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn draft_id(mut self, value: DraftId) -> Self { + self.draft_id = Some(value); + self + } + + pub fn client_id(mut self, value: DraftClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn labels(mut self, value: DraftLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn reply_to(mut self, value: DraftReplyTo) -> Self { + self.reply_to = Some(value); + self + } + + pub fn to(mut self, value: DraftTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: DraftCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: DraftBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: DraftSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn preview(mut self, value: DraftPreview) -> Self { + self.preview = Some(value); + self + } + + pub fn text(mut self, value: DraftText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: DraftHtml) -> Self { + self.html = Some(value); + self + } + + pub fn attachments(mut self, value: DraftAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn in_reply_to(mut self, value: DraftInReplyTo) -> Self { + self.in_reply_to = Some(value); + self + } + + pub fn forward_of(mut self, value: DraftForwardOf) -> Self { + self.forward_of = Some(value); + self + } + + pub fn references(mut self, value: Vec) -> Self { + self.references = Some(value); + self + } + + pub fn send_status(mut self, value: DraftSendStatus) -> Self { + self.send_status = Some(value); + self + } + + pub fn send_at(mut self, value: DraftSendAt) -> Self { + self.send_at = Some(value); + self + } + + pub fn updated_at(mut self, value: DraftUpdatedAt) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`Draft`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](DraftBuilder::inbox_id) + /// - [`draft_id`](DraftBuilder::draft_id) + /// - [`labels`](DraftBuilder::labels) + /// - [`updated_at`](DraftBuilder::updated_at) + /// - [`created_at`](DraftBuilder::created_at) + pub fn build(self) -> Result { + Ok(Draft { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + draft_id: self.draft_id.ok_or_else(|| BuildError::missing_field("draft_id"))?, + client_id: self.client_id, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + reply_to: self.reply_to, + to: self.to, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + preview: self.preview, + text: self.text, + html: self.html, + attachments: self.attachments, + in_reply_to: self.in_reply_to, + forward_of: self.forward_of, + references: self.references, + send_status: self.send_status, + send_at: self.send_at, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/draft_attachments.rs b/agentmail-types/src/types/draft_attachments.rs new file mode 100644 index 0000000..9ac587d --- /dev/null +++ b/agentmail-types/src/types/draft_attachments.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftAttachments(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_bcc.rs b/agentmail-types/src/types/draft_bcc.rs new file mode 100644 index 0000000..a2b834a --- /dev/null +++ b/agentmail-types/src/types/draft_bcc.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftBcc(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_cc.rs b/agentmail-types/src/types/draft_cc.rs new file mode 100644 index 0000000..c38a4fb --- /dev/null +++ b/agentmail-types/src/types/draft_cc.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftCc(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_client_id.rs b/agentmail-types/src/types/draft_client_id.rs new file mode 100644 index 0000000..55174fa --- /dev/null +++ b/agentmail-types/src/types/draft_client_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftClientId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_forward_of.rs b/agentmail-types/src/types/draft_forward_of.rs new file mode 100644 index 0000000..b9d9c1b --- /dev/null +++ b/agentmail-types/src/types/draft_forward_of.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftForwardOf(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_html.rs b/agentmail-types/src/types/draft_html.rs new file mode 100644 index 0000000..7908b7b --- /dev/null +++ b/agentmail-types/src/types/draft_html.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftHtml(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_id.rs b/agentmail-types/src/types/draft_id.rs new file mode 100644 index 0000000..88227cb --- /dev/null +++ b/agentmail-types/src/types/draft_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_in_reply_to.rs b/agentmail-types/src/types/draft_in_reply_to.rs new file mode 100644 index 0000000..a065065 --- /dev/null +++ b/agentmail-types/src/types/draft_in_reply_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftInReplyTo(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_item.rs b/agentmail-types/src/types/draft_item.rs new file mode 100644 index 0000000..ba88fa3 --- /dev/null +++ b/agentmail-types/src/types/draft_item.rs @@ -0,0 +1,157 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct DraftItem { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub draft_id: DraftId, + #[serde(default)] + pub labels: DraftLabels, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub forward_of: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub send_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub send_at: Option, + #[serde(default)] + pub updated_at: DraftUpdatedAt, +} + +impl DraftItem { + pub fn builder() -> DraftItemBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DraftItemBuilder { + inbox_id: Option, + draft_id: Option, + labels: Option, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + preview: Option, + attachments: Option, + in_reply_to: Option, + forward_of: Option, + send_status: Option, + send_at: Option, + updated_at: Option, +} + +impl DraftItemBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn draft_id(mut self, value: DraftId) -> Self { + self.draft_id = Some(value); + self + } + + pub fn labels(mut self, value: DraftLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn to(mut self, value: DraftTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: DraftCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: DraftBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: DraftSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn preview(mut self, value: DraftPreview) -> Self { + self.preview = Some(value); + self + } + + pub fn attachments(mut self, value: DraftAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn in_reply_to(mut self, value: DraftInReplyTo) -> Self { + self.in_reply_to = Some(value); + self + } + + pub fn forward_of(mut self, value: DraftForwardOf) -> Self { + self.forward_of = Some(value); + self + } + + pub fn send_status(mut self, value: DraftSendStatus) -> Self { + self.send_status = Some(value); + self + } + + pub fn send_at(mut self, value: DraftSendAt) -> Self { + self.send_at = Some(value); + self + } + + pub fn updated_at(mut self, value: DraftUpdatedAt) -> Self { + self.updated_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`DraftItem`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](DraftItemBuilder::inbox_id) + /// - [`draft_id`](DraftItemBuilder::draft_id) + /// - [`labels`](DraftItemBuilder::labels) + /// - [`updated_at`](DraftItemBuilder::updated_at) + pub fn build(self) -> Result { + Ok(DraftItem { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + draft_id: self.draft_id.ok_or_else(|| BuildError::missing_field("draft_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + to: self.to, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + preview: self.preview, + attachments: self.attachments, + in_reply_to: self.in_reply_to, + forward_of: self.forward_of, + send_status: self.send_status, + send_at: self.send_at, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/draft_labels.rs b/agentmail-types/src/types/draft_labels.rs new file mode 100644 index 0000000..36d9b41 --- /dev/null +++ b/agentmail-types/src/types/draft_labels.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftLabels(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_preview.rs b/agentmail-types/src/types/draft_preview.rs new file mode 100644 index 0000000..1fcf310 --- /dev/null +++ b/agentmail-types/src/types/draft_preview.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftPreview(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_reply_all.rs b/agentmail-types/src/types/draft_reply_all.rs new file mode 100644 index 0000000..8444fd6 --- /dev/null +++ b/agentmail-types/src/types/draft_reply_all.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftReplyAll(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_reply_to.rs b/agentmail-types/src/types/draft_reply_to.rs new file mode 100644 index 0000000..49f0267 --- /dev/null +++ b/agentmail-types/src/types/draft_reply_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftReplyTo(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_send_at.rs b/agentmail-types/src/types/draft_send_at.rs new file mode 100644 index 0000000..aeb4805 --- /dev/null +++ b/agentmail-types/src/types/draft_send_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftSendAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_send_status.rs b/agentmail-types/src/types/draft_send_status.rs new file mode 100644 index 0000000..52b0619 --- /dev/null +++ b/agentmail-types/src/types/draft_send_status.rs @@ -0,0 +1,49 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Schedule send status of draft. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DraftSendStatus { + Scheduled, + Sending, + Failed, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for DraftSendStatus { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Scheduled => serializer.serialize_str("scheduled"), + Self::Sending => serializer.serialize_str("sending"), + Self::Failed => serializer.serialize_str("failed"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for DraftSendStatus { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "scheduled" => Ok(Self::Scheduled), + "sending" => Ok(Self::Sending), + "failed" => Ok(Self::Failed), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for DraftSendStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Scheduled => write!(f, "scheduled"), + Self::Sending => write!(f, "sending"), + Self::Failed => write!(f, "failed"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/draft_subject.rs b/agentmail-types/src/types/draft_subject.rs new file mode 100644 index 0000000..cab42bc --- /dev/null +++ b/agentmail-types/src/types/draft_subject.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftSubject(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_text.rs b/agentmail-types/src/types/draft_text.rs new file mode 100644 index 0000000..4454868 --- /dev/null +++ b/agentmail-types/src/types/draft_text.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftText(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_to.rs b/agentmail-types/src/types/draft_to.rs new file mode 100644 index 0000000..3628176 --- /dev/null +++ b/agentmail-types/src/types/draft_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftTo(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/draft_updated_at.rs b/agentmail-types/src/types/draft_updated_at.rs new file mode 100644 index 0000000..02d2b5d --- /dev/null +++ b/agentmail-types/src/types/draft_updated_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct DraftUpdatedAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/drafts_list_query_request.rs b/agentmail-types/src/types/drafts_list_query_request.rs new file mode 100644 index 0000000..6ffd971 --- /dev/null +++ b/agentmail-types/src/types/drafts_list_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct DraftsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl DraftsListQueryRequest { + pub fn builder() -> DraftsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct DraftsListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, +} + +impl DraftsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`DraftsListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](DraftsListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(DraftsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/end.rs b/agentmail-types/src/types/end.rs new file mode 100644 index 0000000..6ffcdf6 --- /dev/null +++ b/agentmail-types/src/types/end.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct End( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/entry_type.rs b/agentmail-types/src/types/entry_type.rs new file mode 100644 index 0000000..16cbbe4 --- /dev/null +++ b/agentmail-types/src/types/entry_type.rs @@ -0,0 +1,45 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Whether the entry is an email address or domain. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum EntryType { + Email, + Domain, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for EntryType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Email => serializer.serialize_str("email"), + Self::Domain => serializer.serialize_str("domain"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for EntryType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "email" => Ok(Self::Email), + "domain" => Ok(Self::Domain), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for EntryType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Email => write!(f, "email"), + Self::Domain => write!(f, "domain"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/error_code.rs b/agentmail-types/src/types/error_code.rs new file mode 100644 index 0000000..c8da052 --- /dev/null +++ b/agentmail-types/src/types/error_code.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ErrorCode(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/error_docs.rs b/agentmail-types/src/types/error_docs.rs new file mode 100644 index 0000000..31c3166 --- /dev/null +++ b/agentmail-types/src/types/error_docs.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ErrorDocs(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/error_fix.rs b/agentmail-types/src/types/error_fix.rs new file mode 100644 index 0000000..a2c459c --- /dev/null +++ b/agentmail-types/src/types/error_fix.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ErrorFix(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/error_message.rs b/agentmail-types/src/types/error_message.rs new file mode 100644 index 0000000..3068c23 --- /dev/null +++ b/agentmail-types/src/types/error_message.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ErrorMessage(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/error_model.rs b/agentmail-types/src/types/error_model.rs new file mode 100644 index 0000000..07b4370 --- /dev/null +++ b/agentmail-types/src/types/error_model.rs @@ -0,0 +1,56 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Error { + pub r#type: ErrorType, + #[serde(default)] + pub name: ErrorName, + #[serde(default)] + pub message: ErrorMessage, +} + +impl Error { + pub fn builder() -> ErrorBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ErrorBuilder { + r#type: Option, + name: Option, + message: Option, +} + +impl ErrorBuilder { + pub fn r#type(mut self, value: ErrorType) -> Self { + self.r#type = Some(value); + self + } + + pub fn name(mut self, value: ErrorName) -> Self { + self.name = Some(value); + self + } + + pub fn message(mut self, value: ErrorMessage) -> Self { + self.message = Some(value); + self + } + + /// Consumes the builder and constructs a [`Error`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](ErrorBuilder::r#type) + /// - [`name`](ErrorBuilder::name) + /// - [`message`](ErrorBuilder::message) + pub fn build(self) -> Result { + Ok(Error { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + message: self.message.ok_or_else(|| BuildError::missing_field("message"))?, + }) + } +} diff --git a/agentmail-types/src/types/error_name.rs b/agentmail-types/src/types/error_name.rs new file mode 100644 index 0000000..8c63ae8 --- /dev/null +++ b/agentmail-types/src/types/error_name.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ErrorName(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/error_response.rs b/agentmail-types/src/types/error_response.rs new file mode 100644 index 0000000..76c9fd7 --- /dev/null +++ b/agentmail-types/src/types/error_response.rs @@ -0,0 +1,74 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ErrorResponse { + #[serde(default)] + pub name: ErrorName, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(default)] + pub message: ErrorMessage, + #[serde(skip_serializing_if = "Option::is_none")] + pub fix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub docs: Option, +} + +impl ErrorResponse { + pub fn builder() -> ErrorResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ErrorResponseBuilder { + name: Option, + code: Option, + message: Option, + fix: Option, + docs: Option, +} + +impl ErrorResponseBuilder { + pub fn name(mut self, value: ErrorName) -> Self { + self.name = Some(value); + self + } + + pub fn code(mut self, value: ErrorCode) -> Self { + self.code = Some(value); + self + } + + pub fn message(mut self, value: ErrorMessage) -> Self { + self.message = Some(value); + self + } + + pub fn fix(mut self, value: ErrorFix) -> Self { + self.fix = Some(value); + self + } + + pub fn docs(mut self, value: ErrorDocs) -> Self { + self.docs = Some(value); + self + } + + /// Consumes the builder and constructs a [`ErrorResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`name`](ErrorResponseBuilder::name) + /// - [`message`](ErrorResponseBuilder::message) + pub fn build(self) -> Result { + Ok(ErrorResponse { + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + code: self.code, + message: self.message.ok_or_else(|| BuildError::missing_field("message"))?, + fix: self.fix, + docs: self.docs, + }) + } +} diff --git a/agentmail-types/src/types/error_type.rs b/agentmail-types/src/types/error_type.rs new file mode 100644 index 0000000..2f5597c --- /dev/null +++ b/agentmail-types/src/types/error_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum ErrorType { + #[serde(rename = "error")] + Error, +} +impl fmt::Display for ErrorType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Error => "error", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/event_id.rs b/agentmail-types/src/types/event_id.rs new file mode 100644 index 0000000..1d997fd --- /dev/null +++ b/agentmail-types/src/types/event_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct EventId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/event_type.rs b/agentmail-types/src/types/event_type.rs new file mode 100644 index 0000000..f084b44 --- /dev/null +++ b/agentmail-types/src/types/event_type.rs @@ -0,0 +1,80 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum EventType { + MessageReceived, + MessageReceivedSpam, + MessageReceivedBlocked, + MessageReceivedUnauthenticated, + MessageSent, + MessageDelivered, + MessageBounced, + MessageComplained, + MessageRejected, + MessageOpened, + DomainVerified, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for EventType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::MessageReceived => serializer.serialize_str("message.received"), + Self::MessageReceivedSpam => serializer.serialize_str("message.received.spam"), + Self::MessageReceivedBlocked => serializer.serialize_str("message.received.blocked"), + Self::MessageReceivedUnauthenticated => serializer.serialize_str("message.received.unauthenticated"), + Self::MessageSent => serializer.serialize_str("message.sent"), + Self::MessageDelivered => serializer.serialize_str("message.delivered"), + Self::MessageBounced => serializer.serialize_str("message.bounced"), + Self::MessageComplained => serializer.serialize_str("message.complained"), + Self::MessageRejected => serializer.serialize_str("message.rejected"), + Self::MessageOpened => serializer.serialize_str("message.opened"), + Self::DomainVerified => serializer.serialize_str("domain.verified"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for EventType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "message.received" => Ok(Self::MessageReceived), + "message.received.spam" => Ok(Self::MessageReceivedSpam), + "message.received.blocked" => Ok(Self::MessageReceivedBlocked), + "message.received.unauthenticated" => Ok(Self::MessageReceivedUnauthenticated), + "message.sent" => Ok(Self::MessageSent), + "message.delivered" => Ok(Self::MessageDelivered), + "message.bounced" => Ok(Self::MessageBounced), + "message.complained" => Ok(Self::MessageComplained), + "message.rejected" => Ok(Self::MessageRejected), + "message.opened" => Ok(Self::MessageOpened), + "domain.verified" => Ok(Self::DomainVerified), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for EventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MessageReceived => write!(f, "message.received"), + Self::MessageReceivedSpam => write!(f, "message.received.spam"), + Self::MessageReceivedBlocked => write!(f, "message.received.blocked"), + Self::MessageReceivedUnauthenticated => write!(f, "message.received.unauthenticated"), + Self::MessageSent => write!(f, "message.sent"), + Self::MessageDelivered => write!(f, "message.delivered"), + Self::MessageBounced => write!(f, "message.bounced"), + Self::MessageComplained => write!(f, "message.complained"), + Self::MessageRejected => write!(f, "message.rejected"), + Self::MessageOpened => write!(f, "message.opened"), + Self::DomainVerified => write!(f, "domain.verified"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/event_types.rs b/agentmail-types/src/types/event_types.rs new file mode 100644 index 0000000..6a1633e --- /dev/null +++ b/agentmail-types/src/types/event_types.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct EventTypes(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/feedback_enabled.rs b/agentmail-types/src/types/feedback_enabled.rs new file mode 100644 index 0000000..398556e --- /dev/null +++ b/agentmail-types/src/types/feedback_enabled.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct FeedbackEnabled(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/identity.rs b/agentmail-types/src/types/identity.rs new file mode 100644 index 0000000..ab34b38 --- /dev/null +++ b/agentmail-types/src/types/identity.rs @@ -0,0 +1,89 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Identity and scope of the authenticated credential. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Identity { + pub scope_type: ScopeType, + /// ID of the most specific scope the credential is bound to. + /// Equals inbox_id when scope_type is inbox, pod_id when pod, organization_id when organization. + #[serde(default)] + pub scope_id: String, + #[serde(default)] + pub organization_id: OrganizationId, + /// ID of the pod the credential is scoped to. Present when scope_type is pod or inbox. + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_id: Option, + /// ID of the inbox the credential is scoped to. Present when scope_type is inbox. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_id: Option, + /// ID of the API key used to authenticate. Absent for JWT and proxy credentials. + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key_id: Option, +} + +impl Identity { + pub fn builder() -> IdentityBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct IdentityBuilder { + scope_type: Option, + scope_id: Option, + organization_id: Option, + pod_id: Option, + inbox_id: Option, + api_key_id: Option, +} + +impl IdentityBuilder { + pub fn scope_type(mut self, value: ScopeType) -> Self { + self.scope_type = Some(value); + self + } + + pub fn scope_id(mut self, value: impl Into) -> Self { + self.scope_id = Some(value.into()); + self + } + + pub fn organization_id(mut self, value: OrganizationId) -> Self { + self.organization_id = Some(value); + self + } + + pub fn pod_id(mut self, value: impl Into) -> Self { + self.pod_id = Some(value.into()); + self + } + + pub fn inbox_id(mut self, value: impl Into) -> Self { + self.inbox_id = Some(value.into()); + self + } + + pub fn api_key_id(mut self, value: impl Into) -> Self { + self.api_key_id = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`Identity`]. + /// This method will fail if any of the following fields are not set: + /// - [`scope_type`](IdentityBuilder::scope_type) + /// - [`scope_id`](IdentityBuilder::scope_id) + /// - [`organization_id`](IdentityBuilder::organization_id) + pub fn build(self) -> Result { + Ok(Identity { + scope_type: self.scope_type.ok_or_else(|| BuildError::missing_field("scope_type"))?, + scope_id: self.scope_id.ok_or_else(|| BuildError::missing_field("scope_id"))?, + organization_id: self.organization_id.ok_or_else(|| BuildError::missing_field("organization_id"))?, + pod_id: self.pod_id, + inbox_id: self.inbox_id, + api_key_id: self.api_key_id, + }) + } +} diff --git a/agentmail-types/src/types/inbox_event.rs b/agentmail-types/src/types/inbox_event.rs new file mode 100644 index 0000000..90bbac6 --- /dev/null +++ b/agentmail-types/src/types/inbox_event.rs @@ -0,0 +1,123 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct InboxEvent { + #[serde(default)] + pub organization_id: OrganizationId, + /// ID of pod. + #[serde(default)] + pub pod_id: String, + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub event_id: InboxEventId, + pub event_type: InboxEventType, + /// ID of message. + #[serde(default)] + pub message_id: String, + /// Label added or removed. + #[serde(default)] + pub label: String, + /// Time at which the event occurred. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub event_at: DateTime, + /// Time at which the event was recorded. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, +} + +impl InboxEvent { + pub fn builder() -> InboxEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxEventBuilder { + organization_id: Option, + pod_id: Option, + inbox_id: Option, + event_id: Option, + event_type: Option, + message_id: Option, + label: Option, + event_at: Option>, + created_at: Option>, +} + +impl InboxEventBuilder { + pub fn organization_id(mut self, value: OrganizationId) -> Self { + self.organization_id = Some(value); + self + } + + pub fn pod_id(mut self, value: impl Into) -> Self { + self.pod_id = Some(value.into()); + self + } + + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn event_id(mut self, value: InboxEventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn event_type(mut self, value: InboxEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn message_id(mut self, value: impl Into) -> Self { + self.message_id = Some(value.into()); + self + } + + pub fn label(mut self, value: impl Into) -> Self { + self.label = Some(value.into()); + self + } + + pub fn event_at(mut self, value: DateTime) -> Self { + self.event_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`organization_id`](InboxEventBuilder::organization_id) + /// - [`pod_id`](InboxEventBuilder::pod_id) + /// - [`inbox_id`](InboxEventBuilder::inbox_id) + /// - [`event_id`](InboxEventBuilder::event_id) + /// - [`event_type`](InboxEventBuilder::event_type) + /// - [`message_id`](InboxEventBuilder::message_id) + /// - [`label`](InboxEventBuilder::label) + /// - [`event_at`](InboxEventBuilder::event_at) + /// - [`created_at`](InboxEventBuilder::created_at) + pub fn build(self) -> Result { + Ok(InboxEvent { + organization_id: self.organization_id.ok_or_else(|| BuildError::missing_field("organization_id"))?, + pod_id: self.pod_id.ok_or_else(|| BuildError::missing_field("pod_id"))?, + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + label: self.label.ok_or_else(|| BuildError::missing_field("label"))?, + event_at: self.event_at.ok_or_else(|| BuildError::missing_field("event_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/inbox_event_id.rs b/agentmail-types/src/types/inbox_event_id.rs new file mode 100644 index 0000000..3929677 --- /dev/null +++ b/agentmail-types/src/types/inbox_event_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct InboxEventId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/inbox_event_type.rs b/agentmail-types/src/types/inbox_event_type.rs new file mode 100644 index 0000000..78d3c17 --- /dev/null +++ b/agentmail-types/src/types/inbox_event_type.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Type of inbox event. Wire format is dot.case to match the +/// convention used by webhook events (`message.received`, +/// `domain.verified`, etc. in events.yml). Pre-2026-04 these were +/// `label_added`/`label_removed` (snake_case). The Fern enum's `name` +/// field stays uppercase-snake (Fern convention); only the wire +/// `value` changed. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum InboxEventType { + LabelAdded, + LabelRemoved, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for InboxEventType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::LabelAdded => serializer.serialize_str("label.added"), + Self::LabelRemoved => serializer.serialize_str("label.removed"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for InboxEventType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "label.added" => Ok(Self::LabelAdded), + "label.removed" => Ok(Self::LabelRemoved), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for InboxEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LabelAdded => write!(f, "label.added"), + Self::LabelRemoved => write!(f, "label.removed"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/inbox_ids.rs b/agentmail-types/src/types/inbox_ids.rs new file mode 100644 index 0000000..ef4382f --- /dev/null +++ b/agentmail-types/src/types/inbox_ids.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct InboxIds(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/inbox_public_key_scope.rs b/agentmail-types/src/types/inbox_public_key_scope.rs new file mode 100644 index 0000000..696b945 --- /dev/null +++ b/agentmail-types/src/types/inbox_public_key_scope.rs @@ -0,0 +1,39 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Authority over one live inbox incarnation. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxPublicKeyScope { + /// ID of the inbox. + #[serde(default)] + pub id: String, +} + +impl InboxPublicKeyScope { + pub fn builder() -> InboxPublicKeyScopeBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxPublicKeyScopeBuilder { + id: Option, +} + +impl InboxPublicKeyScopeBuilder { + pub fn id(mut self, value: impl Into) -> Self { + self.id = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`InboxPublicKeyScope`]. + /// This method will fail if any of the following fields are not set: + /// - [`id`](InboxPublicKeyScopeBuilder::id) + pub fn build(self) -> Result { + Ok(InboxPublicKeyScope { + id: self.id.ok_or_else(|| BuildError::missing_field("id"))?, + }) + } +} diff --git a/agentmail-types/src/types/inboxes_api_keys_list_query_request.rs b/agentmail-types/src/types/inboxes_api_keys_list_query_request.rs new file mode 100644 index 0000000..bfb2a52 --- /dev/null +++ b/agentmail-types/src/types/inboxes_api_keys_list_query_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesApiKeysListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, +} + +impl InboxesApiKeysListQueryRequest { + pub fn builder() -> InboxesApiKeysListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesApiKeysListQueryRequestBuilder { + limit: Option, + page_token: Option, +} + +impl InboxesApiKeysListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesApiKeysListQueryRequest`]. + pub fn build(self) -> Result { + Ok(InboxesApiKeysListQueryRequest { + limit: self.limit, + page_token: self.page_token, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_client_id.rs b/agentmail-types/src/types/inboxes_client_id.rs new file mode 100644 index 0000000..9f181a4 --- /dev/null +++ b/agentmail-types/src/types/inboxes_client_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct InboxesClientId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/inboxes_create_inbox_request.rs b/agentmail-types/src/types/inboxes_create_inbox_request.rs new file mode 100644 index 0000000..69559ea --- /dev/null +++ b/agentmail-types/src/types/inboxes_create_inbox_request.rs @@ -0,0 +1,76 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct InboxesCreateInboxRequest { + /// Username of address. Randomly generated if not specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + /// Domain of address. Must be a verified domain, or any subdomain of a + /// verified domain that has subdomains enabled (e.g., `bot.example.com`). + /// Defaults to `agentmail.to`. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + /// Custom metadata to attach to the inbox. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl InboxesCreateInboxRequest { + pub fn builder() -> InboxesCreateInboxRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesCreateInboxRequestBuilder { + username: Option, + domain: Option, + display_name: Option, + client_id: Option, + metadata: Option, +} + +impl InboxesCreateInboxRequestBuilder { + pub fn username(mut self, value: impl Into) -> Self { + self.username = Some(value.into()); + self + } + + pub fn domain(mut self, value: impl Into) -> Self { + self.domain = Some(value.into()); + self + } + + pub fn display_name(mut self, value: InboxesDisplayName) -> Self { + self.display_name = Some(value); + self + } + + pub fn client_id(mut self, value: InboxesClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn metadata(mut self, value: InboxesMetadata) -> Self { + self.metadata = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesCreateInboxRequest`]. + pub fn build(self) -> Result { + Ok(InboxesCreateInboxRequest { + username: self.username, + domain: self.domain, + display_name: self.display_name, + client_id: self.client_id, + metadata: self.metadata, + }) + } +} diff --git a/agentmail-types/src/types/inboxes_display_name.rs b/agentmail-types/src/types/inboxes_display_name.rs new file mode 100644 index 0000000..bad026d --- /dev/null +++ b/agentmail-types/src/types/inboxes_display_name.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct InboxesDisplayName(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/inboxes_drafts_list_query_request.rs b/agentmail-types/src/types/inboxes_drafts_list_query_request.rs new file mode 100644 index 0000000..4d562db --- /dev/null +++ b/agentmail-types/src/types/inboxes_drafts_list_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesDraftsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl InboxesDraftsListQueryRequest { + pub fn builder() -> InboxesDraftsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesDraftsListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, +} + +impl InboxesDraftsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesDraftsListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](InboxesDraftsListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(InboxesDraftsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_email.rs b/agentmail-types/src/types/inboxes_email.rs new file mode 100644 index 0000000..b72ffb1 --- /dev/null +++ b/agentmail-types/src/types/inboxes_email.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct InboxesEmail(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/inboxes_events_list_query_request.rs b/agentmail-types/src/types/inboxes_events_list_query_request.rs new file mode 100644 index 0000000..d806bf2 --- /dev/null +++ b/agentmail-types/src/types/inboxes_events_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesEventsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl InboxesEventsListQueryRequest { + pub fn builder() -> InboxesEventsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesEventsListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl InboxesEventsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesEventsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(InboxesEventsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_inbox.rs b/agentmail-types/src/types/inboxes_inbox.rs new file mode 100644 index 0000000..52b7287 --- /dev/null +++ b/agentmail-types/src/types/inboxes_inbox.rs @@ -0,0 +1,109 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct InboxesInbox { + #[serde(default)] + pub pod_id: PodsPodId, + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub email: InboxesEmail, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + /// Custom metadata attached to the inbox. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Time at which inbox was last updated. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, + /// Time at which inbox was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, +} + +impl InboxesInbox { + pub fn builder() -> InboxesInboxBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesInboxBuilder { + pod_id: Option, + inbox_id: Option, + email: Option, + display_name: Option, + client_id: Option, + metadata: Option, + updated_at: Option>, + created_at: Option>, +} + +impl InboxesInboxBuilder { + pub fn pod_id(mut self, value: PodsPodId) -> Self { + self.pod_id = Some(value); + self + } + + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn email(mut self, value: InboxesEmail) -> Self { + self.email = Some(value); + self + } + + pub fn display_name(mut self, value: InboxesDisplayName) -> Self { + self.display_name = Some(value); + self + } + + pub fn client_id(mut self, value: InboxesClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn metadata(mut self, value: InboxesMetadata) -> Self { + self.metadata = Some(value); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesInbox`]. + /// This method will fail if any of the following fields are not set: + /// - [`pod_id`](InboxesInboxBuilder::pod_id) + /// - [`inbox_id`](InboxesInboxBuilder::inbox_id) + /// - [`email`](InboxesInboxBuilder::email) + /// - [`updated_at`](InboxesInboxBuilder::updated_at) + /// - [`created_at`](InboxesInboxBuilder::created_at) + pub fn build(self) -> Result { + Ok(InboxesInbox { + pod_id: self.pod_id.ok_or_else(|| BuildError::missing_field("pod_id"))?, + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + email: self.email.ok_or_else(|| BuildError::missing_field("email"))?, + display_name: self.display_name, + client_id: self.client_id, + metadata: self.metadata, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/inboxes_inbox_id.rs b/agentmail-types/src/types/inboxes_inbox_id.rs new file mode 100644 index 0000000..872714d --- /dev/null +++ b/agentmail-types/src/types/inboxes_inbox_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct InboxesInboxId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/inboxes_list_inboxes_response.rs b/agentmail-types/src/types/inboxes_list_inboxes_response.rs new file mode 100644 index 0000000..ea12fa3 --- /dev/null +++ b/agentmail-types/src/types/inboxes_list_inboxes_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct InboxesListInboxesResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `created_at` descending. + #[serde(default)] + pub inboxes: Vec, +} + +impl InboxesListInboxesResponse { + pub fn builder() -> InboxesListInboxesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesListInboxesResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + inboxes: Option>, +} + +impl InboxesListInboxesResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn inboxes(mut self, value: Vec) -> Self { + self.inboxes = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesListInboxesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](InboxesListInboxesResponseBuilder::count) + /// - [`inboxes`](InboxesListInboxesResponseBuilder::inboxes) + pub fn build(self) -> Result { + Ok(InboxesListInboxesResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + inboxes: self.inboxes.ok_or_else(|| BuildError::missing_field("inboxes"))?, + }) + } +} diff --git a/agentmail-types/src/types/inboxes_list_query_request.rs b/agentmail-types/src/types/inboxes_list_query_request.rs new file mode 100644 index 0000000..1e76bbf --- /dev/null +++ b/agentmail-types/src/types/inboxes_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl InboxesListQueryRequest { + pub fn builder() -> InboxesListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl InboxesListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesListQueryRequest`]. + pub fn build(self) -> Result { + Ok(InboxesListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_lists_list_query_request.rs b/agentmail-types/src/types/inboxes_lists_list_query_request.rs new file mode 100644 index 0000000..394703b --- /dev/null +++ b/agentmail-types/src/types/inboxes_lists_list_query_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesListsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, +} + +impl InboxesListsListQueryRequest { + pub fn builder() -> InboxesListsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesListsListQueryRequestBuilder { + limit: Option, + page_token: Option, +} + +impl InboxesListsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesListsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(InboxesListsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_messages_list_query_request.rs b/agentmail-types/src/types/inboxes_messages_list_query_request.rs new file mode 100644 index 0000000..fe4a1a2 --- /dev/null +++ b/agentmail-types/src/types/inboxes_messages_list_query_request.rs @@ -0,0 +1,150 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesMessagesListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_spam: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_blocked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_unauthenticated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_trash: Option, + /// Filter to messages whose sender contains this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option>, + /// Filter to messages whose recipients (to, cc, or bcc) contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option>, + /// Filter to messages whose subject contains this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, +} + +impl InboxesMessagesListQueryRequest { + pub fn builder() -> InboxesMessagesListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesMessagesListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, + include_spam: Option, + include_blocked: Option, + include_unauthenticated: Option, + include_trash: Option, + from: Option>, + to: Option>, + subject: Option>, +} + +impl InboxesMessagesListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + pub fn include_spam(mut self, value: IncludeSpam) -> Self { + self.include_spam = Some(value); + self + } + + pub fn include_blocked(mut self, value: IncludeBlocked) -> Self { + self.include_blocked = Some(value); + self + } + + pub fn include_unauthenticated(mut self, value: IncludeUnauthenticated) -> Self { + self.include_unauthenticated = Some(value); + self + } + + pub fn include_trash(mut self, value: IncludeTrash) -> Self { + self.include_trash = Some(value); + self + } + + pub fn from(mut self, value: Vec) -> Self { + self.from = Some(value); + self + } + + pub fn to(mut self, value: Vec) -> Self { + self.to = Some(value); + self + } + + pub fn subject(mut self, value: Vec) -> Self { + self.subject = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesMessagesListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](InboxesMessagesListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(InboxesMessagesListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + include_spam: self.include_spam, + include_blocked: self.include_blocked, + include_unauthenticated: self.include_unauthenticated, + include_trash: self.include_trash, + from: self.from, + to: self.to, + subject: self.subject, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_messages_search_query_request.rs b/agentmail-types/src/types/inboxes_messages_search_query_request.rs new file mode 100644 index 0000000..28d8c1d --- /dev/null +++ b/agentmail-types/src/types/inboxes_messages_search_query_request.rs @@ -0,0 +1,75 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for search +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesMessagesSearchQueryRequest { + #[serde(default)] + pub q: Query, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +impl InboxesMessagesSearchQueryRequest { + pub fn builder() -> InboxesMessagesSearchQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesMessagesSearchQueryRequestBuilder { + q: Option, + limit: Option, + page_token: Option, + before: Option, + after: Option, +} + +impl InboxesMessagesSearchQueryRequestBuilder { + pub fn q(mut self, value: Query) -> Self { + self.q = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesMessagesSearchQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`q`](InboxesMessagesSearchQueryRequestBuilder::q) + pub fn build(self) -> Result { + Ok(InboxesMessagesSearchQueryRequest { + q: self.q.ok_or_else(|| BuildError::missing_field("q"))?, + limit: self.limit, + page_token: self.page_token, + before: self.before, + after: self.after, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_metadata.rs b/agentmail-types/src/types/inboxes_metadata.rs new file mode 100644 index 0000000..1953c90 --- /dev/null +++ b/agentmail-types/src/types/inboxes_metadata.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct InboxesMetadata(pub HashMap); \ No newline at end of file diff --git a/agentmail-types/src/types/inboxes_metadata_value.rs b/agentmail-types/src/types/inboxes_metadata_value.rs new file mode 100644 index 0000000..96e97a3 --- /dev/null +++ b/agentmail-types/src/types/inboxes_metadata_value.rs @@ -0,0 +1,80 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum InboxesMetadataValue { + String(String), + + Double(f64), + + Boolean(bool), +} + +impl InboxesMetadataValue { + pub fn is_string(&self) -> bool { + matches!(self, Self::String(_)) + } + + pub fn is_double(&self) -> bool { + matches!(self, Self::Double(_)) + } + + pub fn is_boolean(&self) -> bool { + matches!(self, Self::Boolean(_)) + } + + + pub fn as_string(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn into_string(self) -> Option { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn as_double(&self) -> Option<&f64> { + match self { + Self::Double(value) => Some(value), + _ => None, + } + } + + pub fn into_double(self) -> Option { + match self { + Self::Double(value) => Some(value), + _ => None, + } + } + + pub fn as_boolean(&self) -> Option<&bool> { + match self { + Self::Boolean(value) => Some(value), + _ => None, + } + } + + pub fn into_boolean(self) -> Option { + match self { + Self::Boolean(value) => Some(value), + _ => None, + } + } +} + +impl fmt::Display for InboxesMetadataValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::String(value) => write!(f, "{}", value), + Self::Double(value) => write!(f, "{}", value), + Self::Boolean(value) => write!(f, "{}", value), + } + } +} diff --git a/agentmail-types/src/types/inboxes_metrics_query_events_query_request.rs b/agentmail-types/src/types/inboxes_metrics_query_events_query_request.rs new file mode 100644 index 0000000..9daea0d --- /dev/null +++ b/agentmail-types/src/types/inboxes_metrics_query_events_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for query-events +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesMetricsQueryEventsQueryRequest { + #[serde(default)] + pub event_types: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descending: Option, +} + +impl InboxesMetricsQueryEventsQueryRequest { + pub fn builder() -> InboxesMetricsQueryEventsQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesMetricsQueryEventsQueryRequestBuilder { + event_types: Option>>, + start: Option, + end: Option, + period: Option, + limit: Option, + descending: Option, +} + +impl InboxesMetricsQueryEventsQueryRequestBuilder { + pub fn event_types(mut self, value: Vec>) -> Self { + self.event_types = Some(value); + self + } + + pub fn start(mut self, value: Start) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: End) -> Self { + self.end = Some(value); + self + } + + pub fn period(mut self, value: Period) -> Self { + self.period = Some(value); + self + } + + pub fn limit(mut self, value: MetricLimit) -> Self { + self.limit = Some(value); + self + } + + pub fn descending(mut self, value: Descending) -> Self { + self.descending = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesMetricsQueryEventsQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`event_types`](InboxesMetricsQueryEventsQueryRequestBuilder::event_types) + pub fn build(self) -> Result { + Ok(InboxesMetricsQueryEventsQueryRequest { + event_types: self.event_types.ok_or_else(|| BuildError::missing_field("event_types"))?, + start: self.start, + end: self.end, + period: self.period, + limit: self.limit, + descending: self.descending, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_metrics_query_usage_query_request.rs b/agentmail-types/src/types/inboxes_metrics_query_usage_query_request.rs new file mode 100644 index 0000000..c6d1d47 --- /dev/null +++ b/agentmail-types/src/types/inboxes_metrics_query_usage_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for query-usage +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesMetricsQueryUsageQueryRequest { + #[serde(default)] + pub usage_types: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descending: Option, +} + +impl InboxesMetricsQueryUsageQueryRequest { + pub fn builder() -> InboxesMetricsQueryUsageQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesMetricsQueryUsageQueryRequestBuilder { + usage_types: Option>>, + start: Option, + end: Option, + period: Option, + limit: Option, + descending: Option, +} + +impl InboxesMetricsQueryUsageQueryRequestBuilder { + pub fn usage_types(mut self, value: Vec>) -> Self { + self.usage_types = Some(value); + self + } + + pub fn start(mut self, value: Start) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: End) -> Self { + self.end = Some(value); + self + } + + pub fn period(mut self, value: Period) -> Self { + self.period = Some(value); + self + } + + pub fn limit(mut self, value: MetricLimit) -> Self { + self.limit = Some(value); + self + } + + pub fn descending(mut self, value: Descending) -> Self { + self.descending = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesMetricsQueryUsageQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`usage_types`](InboxesMetricsQueryUsageQueryRequestBuilder::usage_types) + pub fn build(self) -> Result { + Ok(InboxesMetricsQueryUsageQueryRequest { + usage_types: self.usage_types.ok_or_else(|| BuildError::missing_field("usage_types"))?, + start: self.start, + end: self.end, + period: self.period, + limit: self.limit, + descending: self.descending, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_threads_list_query_request.rs b/agentmail-types/src/types/inboxes_threads_list_query_request.rs new file mode 100644 index 0000000..69b3aa5 --- /dev/null +++ b/agentmail-types/src/types/inboxes_threads_list_query_request.rs @@ -0,0 +1,150 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesThreadsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_spam: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_blocked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_unauthenticated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_trash: Option, + /// Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub senders: Option>, + /// Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients: Option>, + /// Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, +} + +impl InboxesThreadsListQueryRequest { + pub fn builder() -> InboxesThreadsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesThreadsListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, + include_spam: Option, + include_blocked: Option, + include_unauthenticated: Option, + include_trash: Option, + senders: Option>, + recipients: Option>, + subject: Option>, +} + +impl InboxesThreadsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + pub fn include_spam(mut self, value: IncludeSpam) -> Self { + self.include_spam = Some(value); + self + } + + pub fn include_blocked(mut self, value: IncludeBlocked) -> Self { + self.include_blocked = Some(value); + self + } + + pub fn include_unauthenticated(mut self, value: IncludeUnauthenticated) -> Self { + self.include_unauthenticated = Some(value); + self + } + + pub fn include_trash(mut self, value: IncludeTrash) -> Self { + self.include_trash = Some(value); + self + } + + pub fn senders(mut self, value: Vec) -> Self { + self.senders = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: Vec) -> Self { + self.subject = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesThreadsListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](InboxesThreadsListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(InboxesThreadsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + include_spam: self.include_spam, + include_blocked: self.include_blocked, + include_unauthenticated: self.include_unauthenticated, + include_trash: self.include_trash, + senders: self.senders, + recipients: self.recipients, + subject: self.subject, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_threads_search_query_request.rs b/agentmail-types/src/types/inboxes_threads_search_query_request.rs new file mode 100644 index 0000000..d1b424c --- /dev/null +++ b/agentmail-types/src/types/inboxes_threads_search_query_request.rs @@ -0,0 +1,75 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for search +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesThreadsSearchQueryRequest { + #[serde(default)] + pub q: Query, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +impl InboxesThreadsSearchQueryRequest { + pub fn builder() -> InboxesThreadsSearchQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesThreadsSearchQueryRequestBuilder { + q: Option, + limit: Option, + page_token: Option, + before: Option, + after: Option, +} + +impl InboxesThreadsSearchQueryRequestBuilder { + pub fn q(mut self, value: Query) -> Self { + self.q = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesThreadsSearchQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`q`](InboxesThreadsSearchQueryRequestBuilder::q) + pub fn build(self) -> Result { + Ok(InboxesThreadsSearchQueryRequest { + q: self.q.ok_or_else(|| BuildError::missing_field("q"))?, + limit: self.limit, + page_token: self.page_token, + before: self.before, + after: self.after, + }) + } +} + diff --git a/agentmail-types/src/types/inboxes_update_inbox_request.rs b/agentmail-types/src/types/inboxes_update_inbox_request.rs new file mode 100644 index 0000000..c76ec61 --- /dev/null +++ b/agentmail-types/src/types/inboxes_update_inbox_request.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct InboxesUpdateInboxRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// Metadata to merge into the inbox's existing metadata. Keys you include + /// are added or overwritten; keys you omit are left unchanged. To remove a + /// single key, send it with a null value. To clear all metadata, send + /// `metadata` as null. Sending an empty object is rejected; use null to + /// clear. Each update must include at least one of `display_name` or + /// `metadata`. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +impl InboxesUpdateInboxRequest { + pub fn builder() -> InboxesUpdateInboxRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesUpdateInboxRequestBuilder { + display_name: Option, + metadata: Option, +} + +impl InboxesUpdateInboxRequestBuilder { + pub fn display_name(mut self, value: InboxesDisplayName) -> Self { + self.display_name = Some(value); + self + } + + pub fn metadata(mut self, value: InboxesUpdateMetadata) -> Self { + self.metadata = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesUpdateInboxRequest`]. + pub fn build(self) -> Result { + Ok(InboxesUpdateInboxRequest { + display_name: self.display_name, + metadata: self.metadata, + }) + } +} diff --git a/agentmail-types/src/types/inboxes_update_metadata.rs b/agentmail-types/src/types/inboxes_update_metadata.rs new file mode 100644 index 0000000..7bc9862 --- /dev/null +++ b/agentmail-types/src/types/inboxes_update_metadata.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct InboxesUpdateMetadata(pub HashMap); \ No newline at end of file diff --git a/agentmail-types/src/types/inboxes_webhooks_list_query_request.rs b/agentmail-types/src/types/inboxes_webhooks_list_query_request.rs new file mode 100644 index 0000000..5d244ab --- /dev/null +++ b/agentmail-types/src/types/inboxes_webhooks_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct InboxesWebhooksListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl InboxesWebhooksListQueryRequest { + pub fn builder() -> InboxesWebhooksListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct InboxesWebhooksListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl InboxesWebhooksListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`InboxesWebhooksListQueryRequest`]. + pub fn build(self) -> Result { + Ok(InboxesWebhooksListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/include_blocked.rs b/agentmail-types/src/types/include_blocked.rs new file mode 100644 index 0000000..a1ad9e1 --- /dev/null +++ b/agentmail-types/src/types/include_blocked.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct IncludeBlocked(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/include_spam.rs b/agentmail-types/src/types/include_spam.rs new file mode 100644 index 0000000..c199d95 --- /dev/null +++ b/agentmail-types/src/types/include_spam.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct IncludeSpam(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/include_trash.rs b/agentmail-types/src/types/include_trash.rs new file mode 100644 index 0000000..4a56fe5 --- /dev/null +++ b/agentmail-types/src/types/include_trash.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct IncludeTrash(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/include_unauthenticated.rs b/agentmail-types/src/types/include_unauthenticated.rs new file mode 100644 index 0000000..fb492d9 --- /dev/null +++ b/agentmail-types/src/types/include_unauthenticated.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct IncludeUnauthenticated(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/labels.rs b/agentmail-types/src/types/labels.rs new file mode 100644 index 0000000..78e895b --- /dev/null +++ b/agentmail-types/src/types/labels.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Labels(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/limit.rs b/agentmail-types/src/types/limit.rs new file mode 100644 index 0000000..70736fe --- /dev/null +++ b/agentmail-types/src/types/limit.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Limit(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/list_api_keys_response.rs b/agentmail-types/src/types/list_api_keys_response.rs new file mode 100644 index 0000000..dc6468b --- /dev/null +++ b/agentmail-types/src/types/list_api_keys_response.rs @@ -0,0 +1,57 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListApiKeysResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `created_at` descending. + #[serde(default)] + pub api_keys: Vec, +} + +impl ListApiKeysResponse { + pub fn builder() -> ListApiKeysResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListApiKeysResponseBuilder { + count: Option, + next_page_token: Option, + api_keys: Option>, +} + +impl ListApiKeysResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn api_keys(mut self, value: Vec) -> Self { + self.api_keys = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListApiKeysResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListApiKeysResponseBuilder::count) + /// - [`api_keys`](ListApiKeysResponseBuilder::api_keys) + pub fn build(self) -> Result { + Ok(ListApiKeysResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + next_page_token: self.next_page_token, + api_keys: self.api_keys.ok_or_else(|| BuildError::missing_field("api_keys"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_domains_response.rs b/agentmail-types/src/types/list_domains_response.rs new file mode 100644 index 0000000..1b8fbe7 --- /dev/null +++ b/agentmail-types/src/types/list_domains_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListDomainsResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `created_at` descending. + #[serde(default)] + pub domains: Vec, +} + +impl ListDomainsResponse { + pub fn builder() -> ListDomainsResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListDomainsResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + domains: Option>, +} + +impl ListDomainsResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn domains(mut self, value: Vec) -> Self { + self.domains = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListDomainsResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListDomainsResponseBuilder::count) + /// - [`domains`](ListDomainsResponseBuilder::domains) + pub fn build(self) -> Result { + Ok(ListDomainsResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + domains: self.domains.ok_or_else(|| BuildError::missing_field("domains"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_drafts_response.rs b/agentmail-types/src/types/list_drafts_response.rs new file mode 100644 index 0000000..0a99611 --- /dev/null +++ b/agentmail-types/src/types/list_drafts_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListDraftsResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `updated_at` descending. + #[serde(default)] + pub drafts: Vec, +} + +impl ListDraftsResponse { + pub fn builder() -> ListDraftsResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListDraftsResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + drafts: Option>, +} + +impl ListDraftsResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn drafts(mut self, value: Vec) -> Self { + self.drafts = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListDraftsResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListDraftsResponseBuilder::count) + /// - [`drafts`](ListDraftsResponseBuilder::drafts) + pub fn build(self) -> Result { + Ok(ListDraftsResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + drafts: self.drafts.ok_or_else(|| BuildError::missing_field("drafts"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_entry.rs b/agentmail-types/src/types/list_entry.rs new file mode 100644 index 0000000..7cd692e --- /dev/null +++ b/agentmail-types/src/types/list_entry.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ListEntry(pub ListEntryBase); \ No newline at end of file diff --git a/agentmail-types/src/types/list_entry_base.rs b/agentmail-types/src/types/list_entry_base.rs new file mode 100644 index 0000000..59b7938 --- /dev/null +++ b/agentmail-types/src/types/list_entry_base.rs @@ -0,0 +1,107 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct ListEntryBase { + /// Email address or domain of list entry. + #[serde(default)] + pub entry: String, + #[serde(default)] + pub organization_id: OrganizationId, + /// Reason for adding the entry. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub direction: Direction, + pub list_type: ListType, + pub entry_type: EntryType, + /// Time at which entry was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, + /// Whether the entry is read-only and cannot be deleted via the API. + #[serde(skip_serializing_if = "Option::is_none")] + pub read_only: Option, +} + +impl ListEntryBase { + pub fn builder() -> ListEntryBaseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListEntryBaseBuilder { + entry: Option, + organization_id: Option, + reason: Option, + direction: Option, + list_type: Option, + entry_type: Option, + created_at: Option>, + read_only: Option, +} + +impl ListEntryBaseBuilder { + pub fn entry(mut self, value: impl Into) -> Self { + self.entry = Some(value.into()); + self + } + + pub fn organization_id(mut self, value: OrganizationId) -> Self { + self.organization_id = Some(value); + self + } + + pub fn reason(mut self, value: impl Into) -> Self { + self.reason = Some(value.into()); + self + } + + pub fn direction(mut self, value: Direction) -> Self { + self.direction = Some(value); + self + } + + pub fn list_type(mut self, value: ListType) -> Self { + self.list_type = Some(value); + self + } + + pub fn entry_type(mut self, value: EntryType) -> Self { + self.entry_type = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + pub fn read_only(mut self, value: bool) -> Self { + self.read_only = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListEntryBase`]. + /// This method will fail if any of the following fields are not set: + /// - [`entry`](ListEntryBaseBuilder::entry) + /// - [`organization_id`](ListEntryBaseBuilder::organization_id) + /// - [`direction`](ListEntryBaseBuilder::direction) + /// - [`list_type`](ListEntryBaseBuilder::list_type) + /// - [`entry_type`](ListEntryBaseBuilder::entry_type) + /// - [`created_at`](ListEntryBaseBuilder::created_at) + pub fn build(self) -> Result { + Ok(ListEntryBase { + entry: self.entry.ok_or_else(|| BuildError::missing_field("entry"))?, + organization_id: self.organization_id.ok_or_else(|| BuildError::missing_field("organization_id"))?, + reason: self.reason, + direction: self.direction.ok_or_else(|| BuildError::missing_field("direction"))?, + list_type: self.list_type.ok_or_else(|| BuildError::missing_field("list_type"))?, + entry_type: self.entry_type.ok_or_else(|| BuildError::missing_field("entry_type"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + read_only: self.read_only, + }) + } +} diff --git a/agentmail-types/src/types/list_inbox_events_response.rs b/agentmail-types/src/types/list_inbox_events_response.rs new file mode 100644 index 0000000..f036f29 --- /dev/null +++ b/agentmail-types/src/types/list_inbox_events_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListInboxEventsResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `event_id` descending. + #[serde(default)] + pub events: Vec, +} + +impl ListInboxEventsResponse { + pub fn builder() -> ListInboxEventsResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListInboxEventsResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + events: Option>, +} + +impl ListInboxEventsResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn events(mut self, value: Vec) -> Self { + self.events = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListInboxEventsResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListInboxEventsResponseBuilder::count) + /// - [`events`](ListInboxEventsResponseBuilder::events) + pub fn build(self) -> Result { + Ok(ListInboxEventsResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + events: self.events.ok_or_else(|| BuildError::missing_field("events"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_list_entries_response.rs b/agentmail-types/src/types/list_list_entries_response.rs new file mode 100644 index 0000000..b99620b --- /dev/null +++ b/agentmail-types/src/types/list_list_entries_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListListEntriesResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by entry ascending. + #[serde(default)] + pub entries: Vec, +} + +impl ListListEntriesResponse { + pub fn builder() -> ListListEntriesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListListEntriesResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + entries: Option>, +} + +impl ListListEntriesResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn entries(mut self, value: Vec) -> Self { + self.entries = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListListEntriesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListListEntriesResponseBuilder::count) + /// - [`entries`](ListListEntriesResponseBuilder::entries) + pub fn build(self) -> Result { + Ok(ListListEntriesResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + entries: self.entries.ok_or_else(|| BuildError::missing_field("entries"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_messages_response.rs b/agentmail-types/src/types/list_messages_response.rs new file mode 100644 index 0000000..77cd611 --- /dev/null +++ b/agentmail-types/src/types/list_messages_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct ListMessagesResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `timestamp` descending. + #[serde(default)] + pub messages: Vec, +} + +impl ListMessagesResponse { + pub fn builder() -> ListMessagesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListMessagesResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + messages: Option>, +} + +impl ListMessagesResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn messages(mut self, value: Vec) -> Self { + self.messages = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListMessagesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListMessagesResponseBuilder::count) + /// - [`messages`](ListMessagesResponseBuilder::messages) + pub fn build(self) -> Result { + Ok(ListMessagesResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + messages: self.messages.ok_or_else(|| BuildError::missing_field("messages"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_public_keys_query_request.rs b/agentmail-types/src/types/list_public_keys_query_request.rs new file mode 100644 index 0000000..0b4b968 --- /dev/null +++ b/agentmail-types/src/types/list_public_keys_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list-public-keys +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListPublicKeysQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl ListPublicKeysQueryRequest { + pub fn builder() -> ListPublicKeysQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListPublicKeysQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl ListPublicKeysQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListPublicKeysQueryRequest`]. + pub fn build(self) -> Result { + Ok(ListPublicKeysQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/list_public_keys_response.rs b/agentmail-types/src/types/list_public_keys_response.rs new file mode 100644 index 0000000..a611e80 --- /dev/null +++ b/agentmail-types/src/types/list_public_keys_response.rs @@ -0,0 +1,57 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListPublicKeysResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Public-key credentials only, ordered by creation time descending by default. + #[serde(default)] + pub public_keys: Vec, +} + +impl ListPublicKeysResponse { + pub fn builder() -> ListPublicKeysResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListPublicKeysResponseBuilder { + count: Option, + next_page_token: Option, + public_keys: Option>, +} + +impl ListPublicKeysResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn public_keys(mut self, value: Vec) -> Self { + self.public_keys = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListPublicKeysResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListPublicKeysResponseBuilder::count) + /// - [`public_keys`](ListPublicKeysResponseBuilder::public_keys) + pub fn build(self) -> Result { + Ok(ListPublicKeysResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + next_page_token: self.next_page_token, + public_keys: self.public_keys.ok_or_else(|| BuildError::missing_field("public_keys"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_threads_response.rs b/agentmail-types/src/types/list_threads_response.rs new file mode 100644 index 0000000..1620ca6 --- /dev/null +++ b/agentmail-types/src/types/list_threads_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListThreadsResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `timestamp` descending. + #[serde(default)] + pub threads: Vec, +} + +impl ListThreadsResponse { + pub fn builder() -> ListThreadsResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListThreadsResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + threads: Option>, +} + +impl ListThreadsResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn threads(mut self, value: Vec) -> Self { + self.threads = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListThreadsResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](ListThreadsResponseBuilder::count) + /// - [`threads`](ListThreadsResponseBuilder::threads) + pub fn build(self) -> Result { + Ok(ListThreadsResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + threads: self.threads.ok_or_else(|| BuildError::missing_field("threads"))?, + }) + } +} diff --git a/agentmail-types/src/types/list_type.rs b/agentmail-types/src/types/list_type.rs new file mode 100644 index 0000000..efbb402 --- /dev/null +++ b/agentmail-types/src/types/list_type.rs @@ -0,0 +1,45 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Type of list entry. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ListType { + Allow, + Block, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for ListType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Allow => serializer.serialize_str("allow"), + Self::Block => serializer.serialize_str("block"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for ListType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "allow" => Ok(Self::Allow), + "block" => Ok(Self::Block), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for ListType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Allow => write!(f, "allow"), + Self::Block => write!(f, "block"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/lists_list_query_request.rs b/agentmail-types/src/types/lists_list_query_request.rs new file mode 100644 index 0000000..28c8547 --- /dev/null +++ b/agentmail-types/src/types/lists_list_query_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ListsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, +} + +impl ListsListQueryRequest { + pub fn builder() -> ListsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ListsListQueryRequestBuilder { + limit: Option, + page_token: Option, +} + +impl ListsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + /// Consumes the builder and constructs a [`ListsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(ListsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + }) + } +} + diff --git a/agentmail-types/src/types/message.rs b/agentmail-types/src/types/message.rs new file mode 100644 index 0000000..ef927a8 --- /dev/null +++ b/agentmail-types/src/types/message.rs @@ -0,0 +1,247 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct Message { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub labels: MessageLabels, + #[serde(default)] + pub timestamp: MessageTimestamp, + #[serde(default)] + pub from: MessageFrom, + /// Reply-to addresses. In format `username@domain.com` or `Display Name `. + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option>, + #[serde(default)] + pub to: MessageTo, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + /// Extracted new text content. + #[serde(skip_serializing_if = "Option::is_none")] + pub extracted_text: Option, + /// Extracted new HTML content. + #[serde(skip_serializing_if = "Option::is_none")] + pub extracted_html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub references: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(default)] + pub size: MessageSize, + #[serde(default)] + pub updated_at: MessageUpdatedAt, + #[serde(default)] + pub created_at: MessageCreatedAt, +} + +impl Message { + pub fn builder() -> MessageBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + labels: Option, + timestamp: Option, + from: Option, + reply_to: Option>, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + preview: Option, + text: Option, + html: Option, + extracted_text: Option, + extracted_html: Option, + attachments: Option, + in_reply_to: Option, + references: Option, + headers: Option, + size: Option, + updated_at: Option, + created_at: Option, +} + +impl MessageBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn labels(mut self, value: MessageLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn timestamp(mut self, value: MessageTimestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn from(mut self, value: MessageFrom) -> Self { + self.from = Some(value); + self + } + + pub fn reply_to(mut self, value: Vec) -> Self { + self.reply_to = Some(value); + self + } + + pub fn to(mut self, value: MessageTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: MessageCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: MessageBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: MessageSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn preview(mut self, value: MessagePreview) -> Self { + self.preview = Some(value); + self + } + + pub fn text(mut self, value: MessageText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: MessageHtml) -> Self { + self.html = Some(value); + self + } + + pub fn extracted_text(mut self, value: impl Into) -> Self { + self.extracted_text = Some(value.into()); + self + } + + pub fn extracted_html(mut self, value: impl Into) -> Self { + self.extracted_html = Some(value.into()); + self + } + + pub fn attachments(mut self, value: MessageAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn in_reply_to(mut self, value: MessageInReplyTo) -> Self { + self.in_reply_to = Some(value); + self + } + + pub fn references(mut self, value: MessageReferences) -> Self { + self.references = Some(value); + self + } + + pub fn headers(mut self, value: MessageHeaders) -> Self { + self.headers = Some(value); + self + } + + pub fn size(mut self, value: MessageSize) -> Self { + self.size = Some(value); + self + } + + pub fn updated_at(mut self, value: MessageUpdatedAt) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: MessageCreatedAt) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`Message`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](MessageBuilder::inbox_id) + /// - [`thread_id`](MessageBuilder::thread_id) + /// - [`message_id`](MessageBuilder::message_id) + /// - [`labels`](MessageBuilder::labels) + /// - [`timestamp`](MessageBuilder::timestamp) + /// - [`from`](MessageBuilder::from) + /// - [`to`](MessageBuilder::to) + /// - [`size`](MessageBuilder::size) + /// - [`updated_at`](MessageBuilder::updated_at) + /// - [`created_at`](MessageBuilder::created_at) + pub fn build(self) -> Result { + Ok(Message { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + from: self.from.ok_or_else(|| BuildError::missing_field("from"))?, + reply_to: self.reply_to, + to: self.to.ok_or_else(|| BuildError::missing_field("to"))?, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + preview: self.preview, + text: self.text, + html: self.html, + extracted_text: self.extracted_text, + extracted_html: self.extracted_html, + attachments: self.attachments, + in_reply_to: self.in_reply_to, + references: self.references, + headers: self.headers, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_attachments.rs b/agentmail-types/src/types/message_attachments.rs new file mode 100644 index 0000000..a609ac7 --- /dev/null +++ b/agentmail-types/src/types/message_attachments.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageAttachments(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_bcc.rs b/agentmail-types/src/types/message_bcc.rs new file mode 100644 index 0000000..5f7591f --- /dev/null +++ b/agentmail-types/src/types/message_bcc.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageBcc(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_bounced_event.rs b/agentmail-types/src/types/message_bounced_event.rs new file mode 100644 index 0000000..501b435 --- /dev/null +++ b/agentmail-types/src/types/message_bounced_event.rs @@ -0,0 +1,65 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct MessageBouncedEvent { + pub r#type: MessageBouncedEventType, + pub event_type: MessageBouncedEventEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub bounce: Bounce, +} + +impl MessageBouncedEvent { + pub fn builder() -> MessageBouncedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageBouncedEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + bounce: Option, +} + +impl MessageBouncedEventBuilder { + pub fn r#type(mut self, value: MessageBouncedEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageBouncedEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn bounce(mut self, value: Bounce) -> Self { + self.bounce = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageBouncedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageBouncedEventBuilder::r#type) + /// - [`event_type`](MessageBouncedEventBuilder::event_type) + /// - [`event_id`](MessageBouncedEventBuilder::event_id) + /// - [`bounce`](MessageBouncedEventBuilder::bounce) + pub fn build(self) -> Result { + Ok(MessageBouncedEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + bounce: self.bounce.ok_or_else(|| BuildError::missing_field("bounce"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_bounced_event_event_type.rs b/agentmail-types/src/types/message_bounced_event_event_type.rs new file mode 100644 index 0000000..542b3f7 --- /dev/null +++ b/agentmail-types/src/types/message_bounced_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageBouncedEventEventType { + #[serde(rename = "message.bounced")] + MessageBounced, +} +impl fmt::Display for MessageBouncedEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::MessageBounced => "message.bounced", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_bounced_event_type.rs b/agentmail-types/src/types/message_bounced_event_type.rs new file mode 100644 index 0000000..1a209ea --- /dev/null +++ b/agentmail-types/src/types/message_bounced_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageBouncedEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageBouncedEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_cc.rs b/agentmail-types/src/types/message_cc.rs new file mode 100644 index 0000000..4b07e2e --- /dev/null +++ b/agentmail-types/src/types/message_cc.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageCc(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_complained_event.rs b/agentmail-types/src/types/message_complained_event.rs new file mode 100644 index 0000000..62afab4 --- /dev/null +++ b/agentmail-types/src/types/message_complained_event.rs @@ -0,0 +1,65 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct MessageComplainedEvent { + pub r#type: MessageComplainedEventType, + pub event_type: MessageComplainedEventEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub complaint: Complaint, +} + +impl MessageComplainedEvent { + pub fn builder() -> MessageComplainedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageComplainedEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + complaint: Option, +} + +impl MessageComplainedEventBuilder { + pub fn r#type(mut self, value: MessageComplainedEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageComplainedEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn complaint(mut self, value: Complaint) -> Self { + self.complaint = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageComplainedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageComplainedEventBuilder::r#type) + /// - [`event_type`](MessageComplainedEventBuilder::event_type) + /// - [`event_id`](MessageComplainedEventBuilder::event_id) + /// - [`complaint`](MessageComplainedEventBuilder::complaint) + pub fn build(self) -> Result { + Ok(MessageComplainedEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + complaint: self.complaint.ok_or_else(|| BuildError::missing_field("complaint"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_complained_event_event_type.rs b/agentmail-types/src/types/message_complained_event_event_type.rs new file mode 100644 index 0000000..b78f3cb --- /dev/null +++ b/agentmail-types/src/types/message_complained_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageComplainedEventEventType { + #[serde(rename = "message.complained")] + MessageComplained, +} +impl fmt::Display for MessageComplainedEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::MessageComplained => "message.complained", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_complained_event_type.rs b/agentmail-types/src/types/message_complained_event_type.rs new file mode 100644 index 0000000..9dcf90a --- /dev/null +++ b/agentmail-types/src/types/message_complained_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageComplainedEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageComplainedEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_created_at.rs b/agentmail-types/src/types/message_created_at.rs new file mode 100644 index 0000000..934aac5 --- /dev/null +++ b/agentmail-types/src/types/message_created_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageCreatedAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/message_delivered_event.rs b/agentmail-types/src/types/message_delivered_event.rs new file mode 100644 index 0000000..77b6976 --- /dev/null +++ b/agentmail-types/src/types/message_delivered_event.rs @@ -0,0 +1,65 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct MessageDeliveredEvent { + pub r#type: MessageDeliveredEventType, + pub event_type: MessageDeliveredEventEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub delivery: Delivery, +} + +impl MessageDeliveredEvent { + pub fn builder() -> MessageDeliveredEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageDeliveredEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + delivery: Option, +} + +impl MessageDeliveredEventBuilder { + pub fn r#type(mut self, value: MessageDeliveredEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageDeliveredEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn delivery(mut self, value: Delivery) -> Self { + self.delivery = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageDeliveredEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageDeliveredEventBuilder::r#type) + /// - [`event_type`](MessageDeliveredEventBuilder::event_type) + /// - [`event_id`](MessageDeliveredEventBuilder::event_id) + /// - [`delivery`](MessageDeliveredEventBuilder::delivery) + pub fn build(self) -> Result { + Ok(MessageDeliveredEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + delivery: self.delivery.ok_or_else(|| BuildError::missing_field("delivery"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_delivered_event_event_type.rs b/agentmail-types/src/types/message_delivered_event_event_type.rs new file mode 100644 index 0000000..cd55576 --- /dev/null +++ b/agentmail-types/src/types/message_delivered_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageDeliveredEventEventType { + #[serde(rename = "message.delivered")] + MessageDelivered, +} +impl fmt::Display for MessageDeliveredEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::MessageDelivered => "message.delivered", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_delivered_event_type.rs b/agentmail-types/src/types/message_delivered_event_type.rs new file mode 100644 index 0000000..7d4da44 --- /dev/null +++ b/agentmail-types/src/types/message_delivered_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageDeliveredEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageDeliveredEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_from.rs b/agentmail-types/src/types/message_from.rs new file mode 100644 index 0000000..dc3919a --- /dev/null +++ b/agentmail-types/src/types/message_from.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageFrom(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_headers.rs b/agentmail-types/src/types/message_headers.rs new file mode 100644 index 0000000..3cd7582 --- /dev/null +++ b/agentmail-types/src/types/message_headers.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct MessageHeaders(pub HashMap); \ No newline at end of file diff --git a/agentmail-types/src/types/message_html.rs b/agentmail-types/src/types/message_html.rs new file mode 100644 index 0000000..95a8111 --- /dev/null +++ b/agentmail-types/src/types/message_html.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageHtml(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_id.rs b/agentmail-types/src/types/message_id.rs new file mode 100644 index 0000000..6fffc55 --- /dev/null +++ b/agentmail-types/src/types/message_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_in_reply_to.rs b/agentmail-types/src/types/message_in_reply_to.rs new file mode 100644 index 0000000..a780859 --- /dev/null +++ b/agentmail-types/src/types/message_in_reply_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageInReplyTo(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_item.rs b/agentmail-types/src/types/message_item.rs new file mode 100644 index 0000000..43e631a --- /dev/null +++ b/agentmail-types/src/types/message_item.rs @@ -0,0 +1,199 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct MessageItem { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub labels: MessageLabels, + #[serde(default)] + pub timestamp: MessageTimestamp, + #[serde(default)] + pub from: MessageFrom, + #[serde(default)] + pub to: MessageTo, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub in_reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub references: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(default)] + pub size: MessageSize, + #[serde(default)] + pub updated_at: MessageUpdatedAt, + #[serde(default)] + pub created_at: MessageCreatedAt, +} + +impl MessageItem { + pub fn builder() -> MessageItemBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageItemBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + labels: Option, + timestamp: Option, + from: Option, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + preview: Option, + attachments: Option, + in_reply_to: Option, + references: Option, + headers: Option, + size: Option, + updated_at: Option, + created_at: Option, +} + +impl MessageItemBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn labels(mut self, value: MessageLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn timestamp(mut self, value: MessageTimestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn from(mut self, value: MessageFrom) -> Self { + self.from = Some(value); + self + } + + pub fn to(mut self, value: MessageTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: MessageCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: MessageBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: MessageSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn preview(mut self, value: MessagePreview) -> Self { + self.preview = Some(value); + self + } + + pub fn attachments(mut self, value: MessageAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn in_reply_to(mut self, value: MessageInReplyTo) -> Self { + self.in_reply_to = Some(value); + self + } + + pub fn references(mut self, value: MessageReferences) -> Self { + self.references = Some(value); + self + } + + pub fn headers(mut self, value: MessageHeaders) -> Self { + self.headers = Some(value); + self + } + + pub fn size(mut self, value: MessageSize) -> Self { + self.size = Some(value); + self + } + + pub fn updated_at(mut self, value: MessageUpdatedAt) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: MessageCreatedAt) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageItem`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](MessageItemBuilder::inbox_id) + /// - [`thread_id`](MessageItemBuilder::thread_id) + /// - [`message_id`](MessageItemBuilder::message_id) + /// - [`labels`](MessageItemBuilder::labels) + /// - [`timestamp`](MessageItemBuilder::timestamp) + /// - [`from`](MessageItemBuilder::from) + /// - [`to`](MessageItemBuilder::to) + /// - [`size`](MessageItemBuilder::size) + /// - [`updated_at`](MessageItemBuilder::updated_at) + /// - [`created_at`](MessageItemBuilder::created_at) + pub fn build(self) -> Result { + Ok(MessageItem { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + from: self.from.ok_or_else(|| BuildError::missing_field("from"))?, + to: self.to.ok_or_else(|| BuildError::missing_field("to"))?, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + preview: self.preview, + attachments: self.attachments, + in_reply_to: self.in_reply_to, + references: self.references, + headers: self.headers, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_labels.rs b/agentmail-types/src/types/message_labels.rs new file mode 100644 index 0000000..e90c90c --- /dev/null +++ b/agentmail-types/src/types/message_labels.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageLabels(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_opened_event.rs b/agentmail-types/src/types/message_opened_event.rs new file mode 100644 index 0000000..0b08663 --- /dev/null +++ b/agentmail-types/src/types/message_opened_event.rs @@ -0,0 +1,67 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// A tracked message was opened for the first time. Sent once per message: repeat opens do not +/// resend it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct MessageOpenedEvent { + pub r#type: MessageOpenedEventType, + pub event_type: MessageOpenedEventEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub open: Open, +} + +impl MessageOpenedEvent { + pub fn builder() -> MessageOpenedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageOpenedEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + open: Option, +} + +impl MessageOpenedEventBuilder { + pub fn r#type(mut self, value: MessageOpenedEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageOpenedEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn open(mut self, value: Open) -> Self { + self.open = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageOpenedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageOpenedEventBuilder::r#type) + /// - [`event_type`](MessageOpenedEventBuilder::event_type) + /// - [`event_id`](MessageOpenedEventBuilder::event_id) + /// - [`open`](MessageOpenedEventBuilder::open) + pub fn build(self) -> Result { + Ok(MessageOpenedEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + open: self.open.ok_or_else(|| BuildError::missing_field("open"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_opened_event_event_type.rs b/agentmail-types/src/types/message_opened_event_event_type.rs new file mode 100644 index 0000000..cbda3fb --- /dev/null +++ b/agentmail-types/src/types/message_opened_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageOpenedEventEventType { + #[serde(rename = "message.opened")] + MessageOpened, +} +impl fmt::Display for MessageOpenedEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::MessageOpened => "message.opened", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_opened_event_type.rs b/agentmail-types/src/types/message_opened_event_type.rs new file mode 100644 index 0000000..a608317 --- /dev/null +++ b/agentmail-types/src/types/message_opened_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageOpenedEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageOpenedEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_preview.rs b/agentmail-types/src/types/message_preview.rs new file mode 100644 index 0000000..791faba --- /dev/null +++ b/agentmail-types/src/types/message_preview.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessagePreview(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_received_event.rs b/agentmail-types/src/types/message_received_event.rs new file mode 100644 index 0000000..d00a54c --- /dev/null +++ b/agentmail-types/src/types/message_received_event.rs @@ -0,0 +1,76 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// A message was received. Spam, blocked, and unauthenticated received-message events use the same payload shape with different `event_type` values. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MessageReceivedEvent { + pub r#type: MessageReceivedEventType, + pub event_type: MessageReceivedEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub message: Message, + #[serde(default)] + pub thread: ThreadItem, +} + +impl MessageReceivedEvent { + pub fn builder() -> MessageReceivedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageReceivedEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + message: Option, + thread: Option, +} + +impl MessageReceivedEventBuilder { + pub fn r#type(mut self, value: MessageReceivedEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageReceivedEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn message(mut self, value: Message) -> Self { + self.message = Some(value); + self + } + + pub fn thread(mut self, value: ThreadItem) -> Self { + self.thread = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageReceivedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageReceivedEventBuilder::r#type) + /// - [`event_type`](MessageReceivedEventBuilder::event_type) + /// - [`event_id`](MessageReceivedEventBuilder::event_id) + /// - [`message`](MessageReceivedEventBuilder::message) + /// - [`thread`](MessageReceivedEventBuilder::thread) + pub fn build(self) -> Result { + Ok(MessageReceivedEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + message: self.message.ok_or_else(|| BuildError::missing_field("message"))?, + thread: self.thread.ok_or_else(|| BuildError::missing_field("thread"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_received_event_type.rs b/agentmail-types/src/types/message_received_event_type.rs new file mode 100644 index 0000000..e64bab9 --- /dev/null +++ b/agentmail-types/src/types/message_received_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageReceivedEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageReceivedEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_references.rs b/agentmail-types/src/types/message_references.rs new file mode 100644 index 0000000..8992ba4 --- /dev/null +++ b/agentmail-types/src/types/message_references.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageReferences(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_rejected_event.rs b/agentmail-types/src/types/message_rejected_event.rs new file mode 100644 index 0000000..73d786b --- /dev/null +++ b/agentmail-types/src/types/message_rejected_event.rs @@ -0,0 +1,65 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct MessageRejectedEvent { + pub r#type: MessageRejectedEventType, + pub event_type: MessageRejectedEventEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub reject: Reject, +} + +impl MessageRejectedEvent { + pub fn builder() -> MessageRejectedEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageRejectedEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + reject: Option, +} + +impl MessageRejectedEventBuilder { + pub fn r#type(mut self, value: MessageRejectedEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageRejectedEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn reject(mut self, value: Reject) -> Self { + self.reject = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageRejectedEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageRejectedEventBuilder::r#type) + /// - [`event_type`](MessageRejectedEventBuilder::event_type) + /// - [`event_id`](MessageRejectedEventBuilder::event_id) + /// - [`reject`](MessageRejectedEventBuilder::reject) + pub fn build(self) -> Result { + Ok(MessageRejectedEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + reject: self.reject.ok_or_else(|| BuildError::missing_field("reject"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_rejected_event_event_type.rs b/agentmail-types/src/types/message_rejected_event_event_type.rs new file mode 100644 index 0000000..cc2cebf --- /dev/null +++ b/agentmail-types/src/types/message_rejected_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageRejectedEventEventType { + #[serde(rename = "message.rejected")] + MessageRejected, +} +impl fmt::Display for MessageRejectedEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::MessageRejected => "message.rejected", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_rejected_event_type.rs b/agentmail-types/src/types/message_rejected_event_type.rs new file mode 100644 index 0000000..20b05b5 --- /dev/null +++ b/agentmail-types/src/types/message_rejected_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageRejectedEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageRejectedEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_reply_to.rs b/agentmail-types/src/types/message_reply_to.rs new file mode 100644 index 0000000..b293b4c --- /dev/null +++ b/agentmail-types/src/types/message_reply_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageReplyTo(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_sent_event.rs b/agentmail-types/src/types/message_sent_event.rs new file mode 100644 index 0000000..6f1f650 --- /dev/null +++ b/agentmail-types/src/types/message_sent_event.rs @@ -0,0 +1,65 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct MessageSentEvent { + pub r#type: MessageSentEventType, + pub event_type: MessageSentEventEventType, + #[serde(default)] + pub event_id: EventId, + #[serde(default)] + pub send: SendEvent, +} + +impl MessageSentEvent { + pub fn builder() -> MessageSentEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MessageSentEventBuilder { + r#type: Option, + event_type: Option, + event_id: Option, + send: Option, +} + +impl MessageSentEventBuilder { + pub fn r#type(mut self, value: MessageSentEventType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_type(mut self, value: MessageSentEventEventType) -> Self { + self.event_type = Some(value); + self + } + + pub fn event_id(mut self, value: EventId) -> Self { + self.event_id = Some(value); + self + } + + pub fn send(mut self, value: SendEvent) -> Self { + self.send = Some(value); + self + } + + /// Consumes the builder and constructs a [`MessageSentEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](MessageSentEventBuilder::r#type) + /// - [`event_type`](MessageSentEventBuilder::event_type) + /// - [`event_id`](MessageSentEventBuilder::event_id) + /// - [`send`](MessageSentEventBuilder::send) + pub fn build(self) -> Result { + Ok(MessageSentEvent { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_type: self.event_type.ok_or_else(|| BuildError::missing_field("event_type"))?, + event_id: self.event_id.ok_or_else(|| BuildError::missing_field("event_id"))?, + send: self.send.ok_or_else(|| BuildError::missing_field("send"))?, + }) + } +} diff --git a/agentmail-types/src/types/message_sent_event_event_type.rs b/agentmail-types/src/types/message_sent_event_event_type.rs new file mode 100644 index 0000000..8adb3f0 --- /dev/null +++ b/agentmail-types/src/types/message_sent_event_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageSentEventEventType { + #[serde(rename = "message.sent")] + MessageSent, +} +impl fmt::Display for MessageSentEventEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::MessageSent => "message.sent", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_sent_event_type.rs b/agentmail-types/src/types/message_sent_event_type.rs new file mode 100644 index 0000000..0cfc3ef --- /dev/null +++ b/agentmail-types/src/types/message_sent_event_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum MessageSentEventType { + #[serde(rename = "event")] + Event, +} +impl fmt::Display for MessageSentEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Event => "event", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/message_size.rs b/agentmail-types/src/types/message_size.rs new file mode 100644 index 0000000..c63f8a5 --- /dev/null +++ b/agentmail-types/src/types/message_size.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageSize(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/message_subject.rs b/agentmail-types/src/types/message_subject.rs new file mode 100644 index 0000000..902ba40 --- /dev/null +++ b/agentmail-types/src/types/message_subject.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageSubject(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_text.rs b/agentmail-types/src/types/message_text.rs new file mode 100644 index 0000000..5e4cb99 --- /dev/null +++ b/agentmail-types/src/types/message_text.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageText(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/message_timestamp.rs b/agentmail-types/src/types/message_timestamp.rs new file mode 100644 index 0000000..f76f205 --- /dev/null +++ b/agentmail-types/src/types/message_timestamp.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageTimestamp( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/message_to.rs b/agentmail-types/src/types/message_to.rs new file mode 100644 index 0000000..9d70d16 --- /dev/null +++ b/agentmail-types/src/types/message_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageTo(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/message_updated_at.rs b/agentmail-types/src/types/message_updated_at.rs new file mode 100644 index 0000000..90b23e3 --- /dev/null +++ b/agentmail-types/src/types/message_updated_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MessageUpdatedAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/metric_bucket.rs b/agentmail-types/src/types/metric_bucket.rs new file mode 100644 index 0000000..0d98d73 --- /dev/null +++ b/agentmail-types/src/types/metric_bucket.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct MetricBucket { + /// Timestamp of the bucket. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub timestamp: DateTime, + /// Count of events in the bucket. + #[serde(default)] + pub count: i64, +} + +impl MetricBucket { + pub fn builder() -> MetricBucketBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MetricBucketBuilder { + timestamp: Option>, + count: Option, +} + +impl MetricBucketBuilder { + pub fn timestamp(mut self, value: DateTime) -> Self { + self.timestamp = Some(value); + self + } + + pub fn count(mut self, value: i64) -> Self { + self.count = Some(value); + self + } + + /// Consumes the builder and constructs a [`MetricBucket`]. + /// This method will fail if any of the following fields are not set: + /// - [`timestamp`](MetricBucketBuilder::timestamp) + /// - [`count`](MetricBucketBuilder::count) + pub fn build(self) -> Result { + Ok(MetricBucket { + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + }) + } +} diff --git a/agentmail-types/src/types/metric_event_type.rs b/agentmail-types/src/types/metric_event_type.rs new file mode 100644 index 0000000..9ed0045 --- /dev/null +++ b/agentmail-types/src/types/metric_event_type.rs @@ -0,0 +1,77 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Type of metric event. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum MetricEventType { + MessageReceived, + MessageReceivedSpam, + MessageReceivedBlocked, + MessageReceivedUnauthenticated, + MessageSent, + MessageDelivered, + MessageBounced, + MessageComplained, + MessageRejected, + DomainVerified, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for MetricEventType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::MessageReceived => serializer.serialize_str("message.received"), + Self::MessageReceivedSpam => serializer.serialize_str("message.received.spam"), + Self::MessageReceivedBlocked => serializer.serialize_str("message.received.blocked"), + Self::MessageReceivedUnauthenticated => serializer.serialize_str("message.received.unauthenticated"), + Self::MessageSent => serializer.serialize_str("message.sent"), + Self::MessageDelivered => serializer.serialize_str("message.delivered"), + Self::MessageBounced => serializer.serialize_str("message.bounced"), + Self::MessageComplained => serializer.serialize_str("message.complained"), + Self::MessageRejected => serializer.serialize_str("message.rejected"), + Self::DomainVerified => serializer.serialize_str("domain.verified"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for MetricEventType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "message.received" => Ok(Self::MessageReceived), + "message.received.spam" => Ok(Self::MessageReceivedSpam), + "message.received.blocked" => Ok(Self::MessageReceivedBlocked), + "message.received.unauthenticated" => Ok(Self::MessageReceivedUnauthenticated), + "message.sent" => Ok(Self::MessageSent), + "message.delivered" => Ok(Self::MessageDelivered), + "message.bounced" => Ok(Self::MessageBounced), + "message.complained" => Ok(Self::MessageComplained), + "message.rejected" => Ok(Self::MessageRejected), + "domain.verified" => Ok(Self::DomainVerified), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for MetricEventType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MessageReceived => write!(f, "message.received"), + Self::MessageReceivedSpam => write!(f, "message.received.spam"), + Self::MessageReceivedBlocked => write!(f, "message.received.blocked"), + Self::MessageReceivedUnauthenticated => write!(f, "message.received.unauthenticated"), + Self::MessageSent => write!(f, "message.sent"), + Self::MessageDelivered => write!(f, "message.delivered"), + Self::MessageBounced => write!(f, "message.bounced"), + Self::MessageComplained => write!(f, "message.complained"), + Self::MessageRejected => write!(f, "message.rejected"), + Self::DomainVerified => write!(f, "domain.verified"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/metric_event_types.rs b/agentmail-types/src/types/metric_event_types.rs new file mode 100644 index 0000000..c0811e9 --- /dev/null +++ b/agentmail-types/src/types/metric_event_types.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MetricEventTypes(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/metric_limit.rs b/agentmail-types/src/types/metric_limit.rs new file mode 100644 index 0000000..84fb1ba --- /dev/null +++ b/agentmail-types/src/types/metric_limit.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct MetricLimit(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/metrics_query_events_query_request.rs b/agentmail-types/src/types/metrics_query_events_query_request.rs new file mode 100644 index 0000000..20ae3cb --- /dev/null +++ b/agentmail-types/src/types/metrics_query_events_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for query-events +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct MetricsQueryEventsQueryRequest { + #[serde(default)] + pub event_types: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descending: Option, +} + +impl MetricsQueryEventsQueryRequest { + pub fn builder() -> MetricsQueryEventsQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MetricsQueryEventsQueryRequestBuilder { + event_types: Option>>, + start: Option, + end: Option, + period: Option, + limit: Option, + descending: Option, +} + +impl MetricsQueryEventsQueryRequestBuilder { + pub fn event_types(mut self, value: Vec>) -> Self { + self.event_types = Some(value); + self + } + + pub fn start(mut self, value: Start) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: End) -> Self { + self.end = Some(value); + self + } + + pub fn period(mut self, value: Period) -> Self { + self.period = Some(value); + self + } + + pub fn limit(mut self, value: MetricLimit) -> Self { + self.limit = Some(value); + self + } + + pub fn descending(mut self, value: Descending) -> Self { + self.descending = Some(value); + self + } + + /// Consumes the builder and constructs a [`MetricsQueryEventsQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`event_types`](MetricsQueryEventsQueryRequestBuilder::event_types) + pub fn build(self) -> Result { + Ok(MetricsQueryEventsQueryRequest { + event_types: self.event_types.ok_or_else(|| BuildError::missing_field("event_types"))?, + start: self.start, + end: self.end, + period: self.period, + limit: self.limit, + descending: self.descending, + }) + } +} + diff --git a/agentmail-types/src/types/metrics_query_usage_query_request.rs b/agentmail-types/src/types/metrics_query_usage_query_request.rs new file mode 100644 index 0000000..98f506f --- /dev/null +++ b/agentmail-types/src/types/metrics_query_usage_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for query-usage +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct MetricsQueryUsageQueryRequest { + #[serde(default)] + pub usage_types: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descending: Option, +} + +impl MetricsQueryUsageQueryRequest { + pub fn builder() -> MetricsQueryUsageQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct MetricsQueryUsageQueryRequestBuilder { + usage_types: Option>>, + start: Option, + end: Option, + period: Option, + limit: Option, + descending: Option, +} + +impl MetricsQueryUsageQueryRequestBuilder { + pub fn usage_types(mut self, value: Vec>) -> Self { + self.usage_types = Some(value); + self + } + + pub fn start(mut self, value: Start) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: End) -> Self { + self.end = Some(value); + self + } + + pub fn period(mut self, value: Period) -> Self { + self.period = Some(value); + self + } + + pub fn limit(mut self, value: MetricLimit) -> Self { + self.limit = Some(value); + self + } + + pub fn descending(mut self, value: Descending) -> Self { + self.descending = Some(value); + self + } + + /// Consumes the builder and constructs a [`MetricsQueryUsageQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`usage_types`](MetricsQueryUsageQueryRequestBuilder::usage_types) + pub fn build(self) -> Result { + Ok(MetricsQueryUsageQueryRequest { + usage_types: self.usage_types.ok_or_else(|| BuildError::missing_field("usage_types"))?, + start: self.start, + end: self.end, + period: self.period, + limit: self.limit, + descending: self.descending, + }) + } +} + diff --git a/agentmail-types/src/types/mod.rs b/agentmail-types/src/types/mod.rs new file mode 100644 index 0000000..3a53a6b --- /dev/null +++ b/agentmail-types/src/types/mod.rs @@ -0,0 +1,638 @@ +//! Request and response types for the AgentMail +//! +//! This module contains all data structures used for API communication, +//! including request bodies, response types, and shared models. +//! +//! ## Type Categories +//! +//! - **Request/Response Types**: 90 types for API operations +//! - **Model Types**: 223 types for data representation + +pub mod limit; +pub mod count; +pub mod page_token; +pub mod labels; +pub mod before; +pub mod after; +pub mod ascending; +pub mod include_spam; +pub mod include_blocked; +pub mod include_unauthenticated; +pub mod include_trash; +pub mod organization_id; +pub mod query; +pub mod error_name; +pub mod error_message; +pub mod error_code; +pub mod error_fix; +pub mod error_docs; +pub mod error_response; +pub mod validation_error_response; +pub mod inboxes_inbox_id; +pub mod inboxes_email; +pub mod inboxes_display_name; +pub mod inboxes_client_id; +pub mod inboxes_metadata_value; +pub mod inboxes_metadata; +pub mod inboxes_update_metadata; +pub mod inboxes_inbox; +pub mod inboxes_list_inboxes_response; +pub mod inboxes_create_inbox_request; +pub mod inboxes_update_inbox_request; +pub mod pods_pod_id; +pub mod pods_name; +pub mod pods_client_id; +pub mod pods_pod; +pub mod pods_list_pods_response; +pub mod webhooks_webhook_id; +pub mod webhooks_client_id; +pub mod webhooks_url; +pub mod webhooks_webhook_headers; +pub mod webhooks_webhook_header_names_response; +pub mod webhooks_webhook; +pub mod webhooks_list_webhooks_response; +pub mod webhooks_create_webhook_event_types; +pub mod webhooks_update_webhook_event_types; +pub mod webhooks_create_inbox_webhook_request; +pub mod webhooks_create_pod_webhook_request; +pub mod webhooks_update_inbox_webhook_request; +pub mod webhooks_update_pod_webhook_request; +pub mod webhooks_update_webhook_headers_request; +pub mod agent_signup_response; +pub mod agent_verify_response; +pub mod api_key_id; +pub mod prefix; +pub mod name; +pub mod created_at; +pub mod public_jwk_coordinate; +pub mod public_jwk_kty; +pub mod public_jwk_crv; +pub mod public_jwk; +pub mod organization_public_key_scope; +pub mod pod_public_key_scope; +pub mod inbox_public_key_scope; +pub mod public_key_scope_zero_type; +pub mod public_key_scope_zero; +pub mod public_key_scope_one_type; +pub mod public_key_scope_one; +pub mod public_key_scope_two_type; +pub mod public_key_scope_two; +pub mod public_key_scope; +pub mod public_key_material; +pub mod public_key_credential_type; +pub mod public_key_credential; +pub mod list_public_keys_response; +pub mod revoke_all_agent_id_sign_in_keys_response; +pub mod api_key_permissions; +pub mod api_key; +pub mod create_api_key_response; +pub mod list_api_keys_response; +pub mod create_api_key_request; +pub mod attachment_id; +pub mod attachment_filename; +pub mod attachment_size; +pub mod attachment_content_type; +pub mod attachment_content_disposition; +pub mod attachment_content_id; +pub mod attachment; +pub mod attachment_response; +pub mod send_attachment; +pub mod scope_type; +pub mod identity; +pub mod domain_id; +pub mod domain_name; +pub mod record_type; +pub mod verification_status; +pub mod record_status; +pub mod verification_record; +pub mod status; +pub mod feedback_enabled; +pub mod subdomains_enabled; +pub mod tracking_enabled; +pub mod client_id; +pub mod domain; +pub mod domain_item; +pub mod list_domains_response; +pub mod create_domain_request; +pub mod update_domain_request; +pub mod draft_id; +pub mod draft_client_id; +pub mod draft_labels; +pub mod draft_reply_to; +pub mod draft_to; +pub mod draft_cc; +pub mod draft_bcc; +pub mod draft_subject; +pub mod draft_preview; +pub mod draft_text; +pub mod draft_html; +pub mod draft_attachments; +pub mod draft_in_reply_to; +pub mod draft_forward_of; +pub mod draft_reply_all; +pub mod draft_send_status; +pub mod draft_send_at; +pub mod draft_updated_at; +pub mod draft_item; +pub mod draft; +pub mod list_drafts_response; +pub mod event_type; +pub mod event_types; +pub mod message_received_event_type; +pub mod pod_ids; +pub mod inbox_ids; +pub mod event_id; +pub mod timestamp; +pub mod recipient; +pub mod send_event; +pub mod delivery; +pub mod bounce; +pub mod complaint; +pub mod reject; +pub mod open; +pub mod message_received_event; +pub mod message_sent_event_type; +pub mod message_sent_event_event_type; +pub mod message_sent_event; +pub mod message_delivered_event_type; +pub mod message_delivered_event_event_type; +pub mod message_delivered_event; +pub mod message_bounced_event_type; +pub mod message_bounced_event_event_type; +pub mod message_bounced_event; +pub mod message_complained_event_type; +pub mod message_complained_event_event_type; +pub mod message_complained_event; +pub mod message_rejected_event_type; +pub mod message_rejected_event_event_type; +pub mod message_rejected_event; +pub mod message_opened_event_type; +pub mod message_opened_event_event_type; +pub mod message_opened_event; +pub mod domain_verified_event_type; +pub mod domain_verified_event_event_type; +pub mod domain_verified_event; +pub mod inbox_event_id; +pub mod inbox_event_type; +pub mod inbox_event; +pub mod list_inbox_events_response; +pub mod direction; +pub mod list_type; +pub mod entry_type; +pub mod list_entry_base; +pub mod list_entry; +pub mod pod_list_entry; +pub mod pod_list_list_entries_response; +pub mod list_list_entries_response; +pub mod create_list_entry_request; +pub mod message_id; +pub mod message_labels; +pub mod message_timestamp; +pub mod message_from; +pub mod message_reply_to; +pub mod message_to; +pub mod message_cc; +pub mod message_bcc; +pub mod message_subject; +pub mod message_preview; +pub mod message_text; +pub mod message_html; +pub mod message_attachments; +pub mod message_in_reply_to; +pub mod message_references; +pub mod message_headers; +pub mod message_size; +pub mod message_updated_at; +pub mod message_created_at; +pub mod message_item; +pub mod message; +pub mod list_messages_response; +pub mod search_message_highlights; +pub mod search_message_item; +pub mod search_messages_response; +pub mod batch_get_messages_message_ids; +pub mod batch_get_messages_response; +pub mod batch_update_messages_message_ids; +pub mod batch_update_messages_response; +pub mod raw_message_response; +pub mod addresses; +pub mod send_message_reply_to; +pub mod send_message_to; +pub mod send_message_cc; +pub mod send_message_bcc; +pub mod send_message_attachments; +pub mod send_message_headers; +pub mod track_opens; +pub mod send_message_request; +pub mod send_message_response; +pub mod update_message_response; +pub mod reply_all; +pub mod update_message_labels; +pub mod update_message_request; +pub mod metric_event_type; +pub mod metric_event_types; +pub mod start; +pub mod end; +pub mod period; +pub mod metric_limit; +pub mod descending; +pub mod metric_bucket; +pub mod query_metrics_response; +pub mod usage_type; +pub mod usage_types; +pub mod usage_point; +pub mod query_usage_response; +pub mod organization; +pub mod thread_id; +pub mod thread_labels; +pub mod thread_timestamp; +pub mod thread_received_timestamp; +pub mod thread_sent_timestamp; +pub mod thread_senders; +pub mod thread_recipients; +pub mod thread_subject; +pub mod thread_preview; +pub mod thread_attachments; +pub mod thread_last_message_id; +pub mod thread_message_count; +pub mod thread_size; +pub mod thread_updated_at; +pub mod thread_created_at; +pub mod thread_item; +pub mod thread; +pub mod update_thread_request; +pub mod update_thread_response; +pub mod list_threads_response; +pub mod search_thread_highlights; +pub mod search_thread_item; +pub mod search_threads_response; +pub mod webhooks_svix_id; +pub mod webhooks_svix_timestamp; +pub mod webhooks_svix_signature; +pub mod subscribe_type; +pub mod subscribe; +pub mod subscribed_type; +pub mod subscribed; +pub mod error_type; +pub mod error_model; +pub mod pods_create_pod_request; +pub mod webhooks_create_webhook_request; +pub mod webhooks_update_webhook_request; +pub mod agent_signup_request; +pub mod agent_verify_request; +pub mod create_public_key_request; +pub mod update_public_key_name_request; +pub mod create_draft_request; +pub mod update_draft_request; +pub mod batch_get_messages_request; +pub mod batch_update_messages_request; +pub mod reply_to_message_request; +pub mod reply_all_message_request; +pub mod inboxes_list_query_request; +pub mod pods_list_query_request; +pub mod webhooks_list_query_request; +pub mod api_keys_list_query_request; +pub mod list_public_keys_query_request; +pub mod domains_list_query_request; +pub mod drafts_list_query_request; +pub mod lists_list_query_request; +pub mod metrics_query_events_query_request; +pub mod metrics_query_usage_query_request; +pub mod threads_list_query_request; +pub mod threads_search_query_request; +pub mod inboxes_api_keys_list_query_request; +pub mod inboxes_drafts_list_query_request; +pub mod inboxes_events_list_query_request; +pub mod inboxes_lists_list_query_request; +pub mod inboxes_messages_list_query_request; +pub mod inboxes_messages_search_query_request; +pub mod inboxes_metrics_query_events_query_request; +pub mod inboxes_metrics_query_usage_query_request; +pub mod inboxes_threads_list_query_request; +pub mod inboxes_threads_search_query_request; +pub mod inboxes_webhooks_list_query_request; +pub mod pods_api_keys_list_query_request; +pub mod pods_domains_list_query_request; +pub mod pods_drafts_list_query_request; +pub mod pods_inboxes_list_query_request; +pub mod pods_lists_list_query_request; +pub mod pods_metrics_query_events_query_request; +pub mod pods_metrics_query_usage_query_request; +pub mod pods_threads_list_query_request; +pub mod pods_threads_search_query_request; +pub mod pods_webhooks_list_query_request; + +pub use limit::Limit; +pub use count::Count; +pub use page_token::PageToken; +pub use labels::Labels; +pub use before::Before; +pub use after::After; +pub use ascending::Ascending; +pub use include_spam::IncludeSpam; +pub use include_blocked::IncludeBlocked; +pub use include_unauthenticated::IncludeUnauthenticated; +pub use include_trash::IncludeTrash; +pub use organization_id::OrganizationId; +pub use query::Query; +pub use error_name::ErrorName; +pub use error_message::ErrorMessage; +pub use error_code::ErrorCode; +pub use error_fix::ErrorFix; +pub use error_docs::ErrorDocs; +pub use error_response::ErrorResponse; +pub use validation_error_response::ValidationErrorResponse; +pub use inboxes_inbox_id::InboxesInboxId; +pub use inboxes_email::InboxesEmail; +pub use inboxes_display_name::InboxesDisplayName; +pub use inboxes_client_id::InboxesClientId; +pub use inboxes_metadata_value::InboxesMetadataValue; +pub use inboxes_metadata::InboxesMetadata; +pub use inboxes_update_metadata::InboxesUpdateMetadata; +pub use inboxes_inbox::InboxesInbox; +pub use inboxes_list_inboxes_response::InboxesListInboxesResponse; +pub use inboxes_create_inbox_request::InboxesCreateInboxRequest; +pub use inboxes_update_inbox_request::InboxesUpdateInboxRequest; +pub use pods_pod_id::PodsPodId; +pub use pods_name::PodsName; +pub use pods_client_id::PodsClientId; +pub use pods_pod::PodsPod; +pub use pods_list_pods_response::PodsListPodsResponse; +pub use webhooks_webhook_id::WebhooksWebhookId; +pub use webhooks_client_id::WebhooksClientId; +pub use webhooks_url::WebhooksUrl; +pub use webhooks_webhook_headers::WebhooksWebhookHeaders; +pub use webhooks_webhook_header_names_response::WebhooksWebhookHeaderNamesResponse; +pub use webhooks_webhook::WebhooksWebhook; +pub use webhooks_list_webhooks_response::WebhooksListWebhooksResponse; +pub use webhooks_create_webhook_event_types::WebhooksCreateWebhookEventTypes; +pub use webhooks_update_webhook_event_types::WebhooksUpdateWebhookEventTypes; +pub use webhooks_create_inbox_webhook_request::WebhooksCreateInboxWebhookRequest; +pub use webhooks_create_pod_webhook_request::WebhooksCreatePodWebhookRequest; +pub use webhooks_update_inbox_webhook_request::WebhooksUpdateInboxWebhookRequest; +pub use webhooks_update_pod_webhook_request::WebhooksUpdatePodWebhookRequest; +pub use webhooks_update_webhook_headers_request::WebhooksUpdateWebhookHeadersRequest; +pub use agent_signup_response::AgentSignupResponse; +pub use agent_verify_response::AgentVerifyResponse; +pub use api_key_id::ApiKeyId; +pub use prefix::Prefix; +pub use name::Name; +pub use created_at::CreatedAt; +pub use public_jwk_coordinate::PublicJwkCoordinate; +pub use public_jwk_kty::PublicJwkKty; +pub use public_jwk_crv::PublicJwkCrv; +pub use public_jwk::PublicJwk; +pub use organization_public_key_scope::OrganizationPublicKeyScope; +pub use pod_public_key_scope::PodPublicKeyScope; +pub use inbox_public_key_scope::InboxPublicKeyScope; +pub use public_key_scope_zero_type::PublicKeyScopeZeroType; +pub use public_key_scope_zero::PublicKeyScopeZero; +pub use public_key_scope_one_type::PublicKeyScopeOneType; +pub use public_key_scope_one::PublicKeyScopeOne; +pub use public_key_scope_two_type::PublicKeyScopeTwoType; +pub use public_key_scope_two::PublicKeyScopeTwo; +pub use public_key_scope::PublicKeyScope; +pub use public_key_material::PublicKeyMaterial; +pub use public_key_credential_type::PublicKeyCredentialType; +pub use public_key_credential::PublicKeyCredential; +pub use list_public_keys_response::ListPublicKeysResponse; +pub use revoke_all_agent_id_sign_in_keys_response::RevokeAllAgentIdSignInKeysResponse; +pub use api_key_permissions::ApiKeyPermissions; +pub use api_key::ApiKey; +pub use create_api_key_response::CreateApiKeyResponse; +pub use list_api_keys_response::ListApiKeysResponse; +pub use create_api_key_request::CreateApiKeyRequest; +pub use attachment_id::AttachmentId; +pub use attachment_filename::AttachmentFilename; +pub use attachment_size::AttachmentSize; +pub use attachment_content_type::AttachmentContentType; +pub use attachment_content_disposition::AttachmentContentDisposition; +pub use attachment_content_id::AttachmentContentId; +pub use attachment::Attachment; +pub use attachment_response::AttachmentResponse; +pub use send_attachment::SendAttachment; +pub use scope_type::ScopeType; +pub use identity::Identity; +pub use domain_id::DomainId; +pub use domain_name::DomainName; +pub use record_type::RecordType; +pub use verification_status::VerificationStatus; +pub use record_status::RecordStatus; +pub use verification_record::VerificationRecord; +pub use status::Status; +pub use feedback_enabled::FeedbackEnabled; +pub use subdomains_enabled::SubdomainsEnabled; +pub use tracking_enabled::TrackingEnabled; +pub use client_id::ClientId; +pub use domain::Domain; +pub use domain_item::DomainItem; +pub use list_domains_response::ListDomainsResponse; +pub use create_domain_request::CreateDomainRequest; +pub use update_domain_request::UpdateDomainRequest; +pub use draft_id::DraftId; +pub use draft_client_id::DraftClientId; +pub use draft_labels::DraftLabels; +pub use draft_reply_to::DraftReplyTo; +pub use draft_to::DraftTo; +pub use draft_cc::DraftCc; +pub use draft_bcc::DraftBcc; +pub use draft_subject::DraftSubject; +pub use draft_preview::DraftPreview; +pub use draft_text::DraftText; +pub use draft_html::DraftHtml; +pub use draft_attachments::DraftAttachments; +pub use draft_in_reply_to::DraftInReplyTo; +pub use draft_forward_of::DraftForwardOf; +pub use draft_reply_all::DraftReplyAll; +pub use draft_send_status::DraftSendStatus; +pub use draft_send_at::DraftSendAt; +pub use draft_updated_at::DraftUpdatedAt; +pub use draft_item::DraftItem; +pub use draft::Draft; +pub use list_drafts_response::ListDraftsResponse; +pub use event_type::EventType; +pub use event_types::EventTypes; +pub use message_received_event_type::MessageReceivedEventType; +pub use pod_ids::PodIds; +pub use inbox_ids::InboxIds; +pub use event_id::EventId; +pub use timestamp::Timestamp; +pub use recipient::Recipient; +pub use send_event::SendEvent; +pub use delivery::Delivery; +pub use bounce::Bounce; +pub use complaint::Complaint; +pub use reject::Reject; +pub use open::Open; +pub use message_received_event::MessageReceivedEvent; +pub use message_sent_event_type::MessageSentEventType; +pub use message_sent_event_event_type::MessageSentEventEventType; +pub use message_sent_event::MessageSentEvent; +pub use message_delivered_event_type::MessageDeliveredEventType; +pub use message_delivered_event_event_type::MessageDeliveredEventEventType; +pub use message_delivered_event::MessageDeliveredEvent; +pub use message_bounced_event_type::MessageBouncedEventType; +pub use message_bounced_event_event_type::MessageBouncedEventEventType; +pub use message_bounced_event::MessageBouncedEvent; +pub use message_complained_event_type::MessageComplainedEventType; +pub use message_complained_event_event_type::MessageComplainedEventEventType; +pub use message_complained_event::MessageComplainedEvent; +pub use message_rejected_event_type::MessageRejectedEventType; +pub use message_rejected_event_event_type::MessageRejectedEventEventType; +pub use message_rejected_event::MessageRejectedEvent; +pub use message_opened_event_type::MessageOpenedEventType; +pub use message_opened_event_event_type::MessageOpenedEventEventType; +pub use message_opened_event::MessageOpenedEvent; +pub use domain_verified_event_type::DomainVerifiedEventType; +pub use domain_verified_event_event_type::DomainVerifiedEventEventType; +pub use domain_verified_event::DomainVerifiedEvent; +pub use inbox_event_id::InboxEventId; +pub use inbox_event_type::InboxEventType; +pub use inbox_event::InboxEvent; +pub use list_inbox_events_response::ListInboxEventsResponse; +pub use direction::Direction; +pub use list_type::ListType; +pub use entry_type::EntryType; +pub use list_entry_base::ListEntryBase; +pub use list_entry::ListEntry; +pub use pod_list_entry::PodListEntry; +pub use pod_list_list_entries_response::PodListListEntriesResponse; +pub use list_list_entries_response::ListListEntriesResponse; +pub use create_list_entry_request::CreateListEntryRequest; +pub use message_id::MessageId; +pub use message_labels::MessageLabels; +pub use message_timestamp::MessageTimestamp; +pub use message_from::MessageFrom; +pub use message_reply_to::MessageReplyTo; +pub use message_to::MessageTo; +pub use message_cc::MessageCc; +pub use message_bcc::MessageBcc; +pub use message_subject::MessageSubject; +pub use message_preview::MessagePreview; +pub use message_text::MessageText; +pub use message_html::MessageHtml; +pub use message_attachments::MessageAttachments; +pub use message_in_reply_to::MessageInReplyTo; +pub use message_references::MessageReferences; +pub use message_headers::MessageHeaders; +pub use message_size::MessageSize; +pub use message_updated_at::MessageUpdatedAt; +pub use message_created_at::MessageCreatedAt; +pub use message_item::MessageItem; +pub use message::Message; +pub use list_messages_response::ListMessagesResponse; +pub use search_message_highlights::SearchMessageHighlights; +pub use search_message_item::SearchMessageItem; +pub use search_messages_response::SearchMessagesResponse; +pub use batch_get_messages_message_ids::BatchGetMessagesMessageIds; +pub use batch_get_messages_response::BatchGetMessagesResponse; +pub use batch_update_messages_message_ids::BatchUpdateMessagesMessageIds; +pub use batch_update_messages_response::BatchUpdateMessagesResponse; +pub use raw_message_response::RawMessageResponse; +pub use addresses::Addresses; +pub use send_message_reply_to::SendMessageReplyTo; +pub use send_message_to::SendMessageTo; +pub use send_message_cc::SendMessageCc; +pub use send_message_bcc::SendMessageBcc; +pub use send_message_attachments::SendMessageAttachments; +pub use send_message_headers::SendMessageHeaders; +pub use track_opens::TrackOpens; +pub use send_message_request::SendMessageRequest; +pub use send_message_response::SendMessageResponse; +pub use update_message_response::UpdateMessageResponse; +pub use reply_all::ReplyAll; +pub use update_message_labels::UpdateMessageLabels; +pub use update_message_request::UpdateMessageRequest; +pub use metric_event_type::MetricEventType; +pub use metric_event_types::MetricEventTypes; +pub use start::Start; +pub use end::End; +pub use period::Period; +pub use metric_limit::MetricLimit; +pub use descending::Descending; +pub use metric_bucket::MetricBucket; +pub use query_metrics_response::QueryMetricsResponse; +pub use usage_type::UsageType; +pub use usage_types::UsageTypes; +pub use usage_point::UsagePoint; +pub use query_usage_response::QueryUsageResponse; +pub use organization::Organization; +pub use thread_id::ThreadId; +pub use thread_labels::ThreadLabels; +pub use thread_timestamp::ThreadTimestamp; +pub use thread_received_timestamp::ThreadReceivedTimestamp; +pub use thread_sent_timestamp::ThreadSentTimestamp; +pub use thread_senders::ThreadSenders; +pub use thread_recipients::ThreadRecipients; +pub use thread_subject::ThreadSubject; +pub use thread_preview::ThreadPreview; +pub use thread_attachments::ThreadAttachments; +pub use thread_last_message_id::ThreadLastMessageId; +pub use thread_message_count::ThreadMessageCount; +pub use thread_size::ThreadSize; +pub use thread_updated_at::ThreadUpdatedAt; +pub use thread_created_at::ThreadCreatedAt; +pub use thread_item::ThreadItem; +pub use thread::Thread; +pub use update_thread_request::UpdateThreadRequest; +pub use update_thread_response::UpdateThreadResponse; +pub use list_threads_response::ListThreadsResponse; +pub use search_thread_highlights::SearchThreadHighlights; +pub use search_thread_item::SearchThreadItem; +pub use search_threads_response::SearchThreadsResponse; +pub use webhooks_svix_id::WebhooksSvixId; +pub use webhooks_svix_timestamp::WebhooksSvixTimestamp; +pub use webhooks_svix_signature::WebhooksSvixSignature; +pub use subscribe_type::SubscribeType; +pub use subscribe::Subscribe; +pub use subscribed_type::SubscribedType; +pub use subscribed::Subscribed; +pub use error_type::ErrorType; +pub use error_model::Error; +pub use pods_create_pod_request::PodsCreatePodRequest; +pub use webhooks_create_webhook_request::WebhooksCreateWebhookRequest; +pub use webhooks_update_webhook_request::WebhooksUpdateWebhookRequest; +pub use agent_signup_request::AgentSignupRequest; +pub use agent_verify_request::AgentVerifyRequest; +pub use create_public_key_request::CreatePublicKeyRequest; +pub use update_public_key_name_request::UpdatePublicKeyNameRequest; +pub use create_draft_request::CreateDraftRequest; +pub use update_draft_request::UpdateDraftRequest; +pub use batch_get_messages_request::BatchGetMessagesRequest; +pub use batch_update_messages_request::BatchUpdateMessagesRequest; +pub use reply_to_message_request::ReplyToMessageRequest; +pub use reply_all_message_request::ReplyAllMessageRequest; +pub use inboxes_list_query_request::InboxesListQueryRequest; +pub use pods_list_query_request::PodsListQueryRequest; +pub use webhooks_list_query_request::WebhooksListQueryRequest; +pub use api_keys_list_query_request::ApiKeysListQueryRequest; +pub use list_public_keys_query_request::ListPublicKeysQueryRequest; +pub use domains_list_query_request::DomainsListQueryRequest; +pub use drafts_list_query_request::DraftsListQueryRequest; +pub use lists_list_query_request::ListsListQueryRequest; +pub use metrics_query_events_query_request::MetricsQueryEventsQueryRequest; +pub use metrics_query_usage_query_request::MetricsQueryUsageQueryRequest; +pub use threads_list_query_request::ThreadsListQueryRequest; +pub use threads_search_query_request::ThreadsSearchQueryRequest; +pub use inboxes_api_keys_list_query_request::InboxesApiKeysListQueryRequest; +pub use inboxes_drafts_list_query_request::InboxesDraftsListQueryRequest; +pub use inboxes_events_list_query_request::InboxesEventsListQueryRequest; +pub use inboxes_lists_list_query_request::InboxesListsListQueryRequest; +pub use inboxes_messages_list_query_request::InboxesMessagesListQueryRequest; +pub use inboxes_messages_search_query_request::InboxesMessagesSearchQueryRequest; +pub use inboxes_metrics_query_events_query_request::InboxesMetricsQueryEventsQueryRequest; +pub use inboxes_metrics_query_usage_query_request::InboxesMetricsQueryUsageQueryRequest; +pub use inboxes_threads_list_query_request::InboxesThreadsListQueryRequest; +pub use inboxes_threads_search_query_request::InboxesThreadsSearchQueryRequest; +pub use inboxes_webhooks_list_query_request::InboxesWebhooksListQueryRequest; +pub use pods_api_keys_list_query_request::PodsApiKeysListQueryRequest; +pub use pods_domains_list_query_request::PodsDomainsListQueryRequest; +pub use pods_drafts_list_query_request::PodsDraftsListQueryRequest; +pub use pods_inboxes_list_query_request::PodsInboxesListQueryRequest; +pub use pods_lists_list_query_request::PodsListsListQueryRequest; +pub use pods_metrics_query_events_query_request::PodsMetricsQueryEventsQueryRequest; +pub use pods_metrics_query_usage_query_request::PodsMetricsQueryUsageQueryRequest; +pub use pods_threads_list_query_request::PodsThreadsListQueryRequest; +pub use pods_threads_search_query_request::PodsThreadsSearchQueryRequest; +pub use pods_webhooks_list_query_request::PodsWebhooksListQueryRequest; + diff --git a/agentmail-types/src/types/name.rs b/agentmail-types/src/types/name.rs new file mode 100644 index 0000000..35d363e --- /dev/null +++ b/agentmail-types/src/types/name.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Name(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/open.rs b/agentmail-types/src/types/open.rs new file mode 100644 index 0000000..0652a6a --- /dev/null +++ b/agentmail-types/src/types/open.rs @@ -0,0 +1,67 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Open { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub timestamp: Timestamp, +} + +impl Open { + pub fn builder() -> OpenBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct OpenBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + timestamp: Option, +} + +impl OpenBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn timestamp(mut self, value: Timestamp) -> Self { + self.timestamp = Some(value); + self + } + + /// Consumes the builder and constructs a [`Open`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](OpenBuilder::inbox_id) + /// - [`thread_id`](OpenBuilder::thread_id) + /// - [`message_id`](OpenBuilder::message_id) + /// - [`timestamp`](OpenBuilder::timestamp) + pub fn build(self) -> Result { + Ok(Open { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + }) + } +} diff --git a/agentmail-types/src/types/organization.rs b/agentmail-types/src/types/organization.rs new file mode 100644 index 0000000..892dc14 --- /dev/null +++ b/agentmail-types/src/types/organization.rs @@ -0,0 +1,154 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Organization details with usage limits and counts. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Organization { + #[serde(default)] + pub organization_id: OrganizationId, + /// Current number of inboxes. + #[serde(default)] + pub inbox_count: i64, + /// Current number of domains. + #[serde(default)] + pub domain_count: i64, + /// Maximum number of inboxes allowed. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_limit: Option, + /// Maximum number of domains allowed. + #[serde(skip_serializing_if = "Option::is_none")] + pub domain_limit: Option, + /// Provider-agnostic billing customer ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub billing_id: Option, + /// Billing provider type (e.g. "stripe"). + #[serde(skip_serializing_if = "Option::is_none")] + pub billing_type: Option, + /// Active billing subscription ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub billing_subscription_id: Option, + /// Provider-agnostic authentication ID. + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication_id: Option, + /// Authentication provider type. + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication_type: Option, + /// Time at which organization was last updated. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, + /// Time at which organization was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, +} + +impl Organization { + pub fn builder() -> OrganizationBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct OrganizationBuilder { + organization_id: Option, + inbox_count: Option, + domain_count: Option, + inbox_limit: Option, + domain_limit: Option, + billing_id: Option, + billing_type: Option, + billing_subscription_id: Option, + authentication_id: Option, + authentication_type: Option, + updated_at: Option>, + created_at: Option>, +} + +impl OrganizationBuilder { + pub fn organization_id(mut self, value: OrganizationId) -> Self { + self.organization_id = Some(value); + self + } + + pub fn inbox_count(mut self, value: i64) -> Self { + self.inbox_count = Some(value); + self + } + + pub fn domain_count(mut self, value: i64) -> Self { + self.domain_count = Some(value); + self + } + + pub fn inbox_limit(mut self, value: i64) -> Self { + self.inbox_limit = Some(value); + self + } + + pub fn domain_limit(mut self, value: i64) -> Self { + self.domain_limit = Some(value); + self + } + + pub fn billing_id(mut self, value: impl Into) -> Self { + self.billing_id = Some(value.into()); + self + } + + pub fn billing_type(mut self, value: impl Into) -> Self { + self.billing_type = Some(value.into()); + self + } + + pub fn billing_subscription_id(mut self, value: impl Into) -> Self { + self.billing_subscription_id = Some(value.into()); + self + } + + pub fn authentication_id(mut self, value: impl Into) -> Self { + self.authentication_id = Some(value.into()); + self + } + + pub fn authentication_type(mut self, value: impl Into) -> Self { + self.authentication_type = Some(value.into()); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`Organization`]. + /// This method will fail if any of the following fields are not set: + /// - [`organization_id`](OrganizationBuilder::organization_id) + /// - [`inbox_count`](OrganizationBuilder::inbox_count) + /// - [`domain_count`](OrganizationBuilder::domain_count) + /// - [`updated_at`](OrganizationBuilder::updated_at) + /// - [`created_at`](OrganizationBuilder::created_at) + pub fn build(self) -> Result { + Ok(Organization { + organization_id: self.organization_id.ok_or_else(|| BuildError::missing_field("organization_id"))?, + inbox_count: self.inbox_count.ok_or_else(|| BuildError::missing_field("inbox_count"))?, + domain_count: self.domain_count.ok_or_else(|| BuildError::missing_field("domain_count"))?, + inbox_limit: self.inbox_limit, + domain_limit: self.domain_limit, + billing_id: self.billing_id, + billing_type: self.billing_type, + billing_subscription_id: self.billing_subscription_id, + authentication_id: self.authentication_id, + authentication_type: self.authentication_type, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/organization_id.rs b/agentmail-types/src/types/organization_id.rs new file mode 100644 index 0000000..a3c38ab --- /dev/null +++ b/agentmail-types/src/types/organization_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct OrganizationId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/organization_public_key_scope.rs b/agentmail-types/src/types/organization_public_key_scope.rs new file mode 100644 index 0000000..6a04d31 --- /dev/null +++ b/agentmail-types/src/types/organization_public_key_scope.rs @@ -0,0 +1,28 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Organization-wide authority. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct OrganizationPublicKeyScope { +} + +impl OrganizationPublicKeyScope { + pub fn builder() -> OrganizationPublicKeyScopeBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct OrganizationPublicKeyScopeBuilder { +} + +impl OrganizationPublicKeyScopeBuilder { + + /// Consumes the builder and constructs a [`OrganizationPublicKeyScope`]. + pub fn build(self) -> Result { + Ok(OrganizationPublicKeyScope { + }) + } +} diff --git a/agentmail-types/src/types/page_token.rs b/agentmail-types/src/types/page_token.rs new file mode 100644 index 0000000..40bb38e --- /dev/null +++ b/agentmail-types/src/types/page_token.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct PageToken(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/period.rs b/agentmail-types/src/types/period.rs new file mode 100644 index 0000000..a3670e1 --- /dev/null +++ b/agentmail-types/src/types/period.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Period(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/pod_ids.rs b/agentmail-types/src/types/pod_ids.rs new file mode 100644 index 0000000..bb2a304 --- /dev/null +++ b/agentmail-types/src/types/pod_ids.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct PodIds(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/pod_list_entry.rs b/agentmail-types/src/types/pod_list_entry.rs new file mode 100644 index 0000000..3dcbbc8 --- /dev/null +++ b/agentmail-types/src/types/pod_list_entry.rs @@ -0,0 +1,58 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PodListEntry { + #[serde(flatten)] + pub list_entry_base_fields: ListEntryBase, + /// ID of pod. + #[serde(default)] + pub pod_id: String, + /// ID of inbox, if entry is inbox-scoped. + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_id: Option, +} + +impl PodListEntry { + pub fn builder() -> PodListEntryBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodListEntryBuilder { + list_entry_base_fields: Option, + pod_id: Option, + inbox_id: Option, +} + +impl PodListEntryBuilder { + pub fn list_entry_base_fields(mut self, value: ListEntryBase) -> Self { + self.list_entry_base_fields = Some(value); + self + } + + pub fn pod_id(mut self, value: impl Into) -> Self { + self.pod_id = Some(value.into()); + self + } + + pub fn inbox_id(mut self, value: impl Into) -> Self { + self.inbox_id = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PodListEntry`]. + /// This method will fail if any of the following fields are not set: + /// - [`list_entry_base_fields`](PodListEntryBuilder::list_entry_base_fields) + /// - [`pod_id`](PodListEntryBuilder::pod_id) + pub fn build(self) -> Result { + Ok(PodListEntry { + list_entry_base_fields: self.list_entry_base_fields.ok_or_else(|| BuildError::missing_field("list_entry_base_fields"))?, + pod_id: self.pod_id.ok_or_else(|| BuildError::missing_field("pod_id"))?, + inbox_id: self.inbox_id, + }) + } +} diff --git a/agentmail-types/src/types/pod_list_list_entries_response.rs b/agentmail-types/src/types/pod_list_list_entries_response.rs new file mode 100644 index 0000000..136c141 --- /dev/null +++ b/agentmail-types/src/types/pod_list_list_entries_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodListListEntriesResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by entry ascending. + #[serde(default)] + pub entries: Vec, +} + +impl PodListListEntriesResponse { + pub fn builder() -> PodListListEntriesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodListListEntriesResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + entries: Option>, +} + +impl PodListListEntriesResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn entries(mut self, value: Vec) -> Self { + self.entries = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodListListEntriesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](PodListListEntriesResponseBuilder::count) + /// - [`entries`](PodListListEntriesResponseBuilder::entries) + pub fn build(self) -> Result { + Ok(PodListListEntriesResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + entries: self.entries.ok_or_else(|| BuildError::missing_field("entries"))?, + }) + } +} diff --git a/agentmail-types/src/types/pod_public_key_scope.rs b/agentmail-types/src/types/pod_public_key_scope.rs new file mode 100644 index 0000000..38f0fa7 --- /dev/null +++ b/agentmail-types/src/types/pod_public_key_scope.rs @@ -0,0 +1,39 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Authority over one live pod and its inboxes. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodPublicKeyScope { + /// ID of the pod. + #[serde(default)] + pub id: String, +} + +impl PodPublicKeyScope { + pub fn builder() -> PodPublicKeyScopeBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodPublicKeyScopeBuilder { + id: Option, +} + +impl PodPublicKeyScopeBuilder { + pub fn id(mut self, value: impl Into) -> Self { + self.id = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PodPublicKeyScope`]. + /// This method will fail if any of the following fields are not set: + /// - [`id`](PodPublicKeyScopeBuilder::id) + pub fn build(self) -> Result { + Ok(PodPublicKeyScope { + id: self.id.ok_or_else(|| BuildError::missing_field("id"))?, + }) + } +} diff --git a/agentmail-types/src/types/pods_api_keys_list_query_request.rs b/agentmail-types/src/types/pods_api_keys_list_query_request.rs new file mode 100644 index 0000000..223f088 --- /dev/null +++ b/agentmail-types/src/types/pods_api_keys_list_query_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsApiKeysListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, +} + +impl PodsApiKeysListQueryRequest { + pub fn builder() -> PodsApiKeysListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsApiKeysListQueryRequestBuilder { + limit: Option, + page_token: Option, +} + +impl PodsApiKeysListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsApiKeysListQueryRequest`]. + pub fn build(self) -> Result { + Ok(PodsApiKeysListQueryRequest { + limit: self.limit, + page_token: self.page_token, + }) + } +} + diff --git a/agentmail-types/src/types/pods_client_id.rs b/agentmail-types/src/types/pods_client_id.rs new file mode 100644 index 0000000..251fb0c --- /dev/null +++ b/agentmail-types/src/types/pods_client_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct PodsClientId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/pods_create_pod_request.rs b/agentmail-types/src/types/pods_create_pod_request.rs new file mode 100644 index 0000000..c1c6d5a --- /dev/null +++ b/agentmail-types/src/types/pods_create_pod_request.rs @@ -0,0 +1,45 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsCreatePodRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +impl PodsCreatePodRequest { + pub fn builder() -> PodsCreatePodRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsCreatePodRequestBuilder { + name: Option, + client_id: Option, +} + +impl PodsCreatePodRequestBuilder { + pub fn name(mut self, value: PodsName) -> Self { + self.name = Some(value); + self + } + + pub fn client_id(mut self, value: PodsClientId) -> Self { + self.client_id = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsCreatePodRequest`]. + pub fn build(self) -> Result { + Ok(PodsCreatePodRequest { + name: self.name, + client_id: self.client_id, + }) + } +} + diff --git a/agentmail-types/src/types/pods_domains_list_query_request.rs b/agentmail-types/src/types/pods_domains_list_query_request.rs new file mode 100644 index 0000000..1cf1322 --- /dev/null +++ b/agentmail-types/src/types/pods_domains_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsDomainsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl PodsDomainsListQueryRequest { + pub fn builder() -> PodsDomainsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsDomainsListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl PodsDomainsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsDomainsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(PodsDomainsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/pods_drafts_list_query_request.rs b/agentmail-types/src/types/pods_drafts_list_query_request.rs new file mode 100644 index 0000000..b04cd14 --- /dev/null +++ b/agentmail-types/src/types/pods_drafts_list_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsDraftsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl PodsDraftsListQueryRequest { + pub fn builder() -> PodsDraftsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsDraftsListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, +} + +impl PodsDraftsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsDraftsListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](PodsDraftsListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(PodsDraftsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/pods_inboxes_list_query_request.rs b/agentmail-types/src/types/pods_inboxes_list_query_request.rs new file mode 100644 index 0000000..cc89432 --- /dev/null +++ b/agentmail-types/src/types/pods_inboxes_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsInboxesListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl PodsInboxesListQueryRequest { + pub fn builder() -> PodsInboxesListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsInboxesListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl PodsInboxesListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsInboxesListQueryRequest`]. + pub fn build(self) -> Result { + Ok(PodsInboxesListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/pods_list_pods_response.rs b/agentmail-types/src/types/pods_list_pods_response.rs new file mode 100644 index 0000000..1f0229e --- /dev/null +++ b/agentmail-types/src/types/pods_list_pods_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsListPodsResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `created_at` descending. + #[serde(default)] + pub pods: Vec, +} + +impl PodsListPodsResponse { + pub fn builder() -> PodsListPodsResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsListPodsResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + pods: Option>, +} + +impl PodsListPodsResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn pods(mut self, value: Vec) -> Self { + self.pods = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsListPodsResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](PodsListPodsResponseBuilder::count) + /// - [`pods`](PodsListPodsResponseBuilder::pods) + pub fn build(self) -> Result { + Ok(PodsListPodsResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + pods: self.pods.ok_or_else(|| BuildError::missing_field("pods"))?, + }) + } +} diff --git a/agentmail-types/src/types/pods_list_query_request.rs b/agentmail-types/src/types/pods_list_query_request.rs new file mode 100644 index 0000000..b029762 --- /dev/null +++ b/agentmail-types/src/types/pods_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl PodsListQueryRequest { + pub fn builder() -> PodsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl PodsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(PodsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/pods_lists_list_query_request.rs b/agentmail-types/src/types/pods_lists_list_query_request.rs new file mode 100644 index 0000000..4a2f579 --- /dev/null +++ b/agentmail-types/src/types/pods_lists_list_query_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsListsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, +} + +impl PodsListsListQueryRequest { + pub fn builder() -> PodsListsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsListsListQueryRequestBuilder { + limit: Option, + page_token: Option, +} + +impl PodsListsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsListsListQueryRequest`]. + pub fn build(self) -> Result { + Ok(PodsListsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + }) + } +} + diff --git a/agentmail-types/src/types/pods_metrics_query_events_query_request.rs b/agentmail-types/src/types/pods_metrics_query_events_query_request.rs new file mode 100644 index 0000000..32afa52 --- /dev/null +++ b/agentmail-types/src/types/pods_metrics_query_events_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for query-events +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsMetricsQueryEventsQueryRequest { + #[serde(default)] + pub event_types: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descending: Option, +} + +impl PodsMetricsQueryEventsQueryRequest { + pub fn builder() -> PodsMetricsQueryEventsQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsMetricsQueryEventsQueryRequestBuilder { + event_types: Option>>, + start: Option, + end: Option, + period: Option, + limit: Option, + descending: Option, +} + +impl PodsMetricsQueryEventsQueryRequestBuilder { + pub fn event_types(mut self, value: Vec>) -> Self { + self.event_types = Some(value); + self + } + + pub fn start(mut self, value: Start) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: End) -> Self { + self.end = Some(value); + self + } + + pub fn period(mut self, value: Period) -> Self { + self.period = Some(value); + self + } + + pub fn limit(mut self, value: MetricLimit) -> Self { + self.limit = Some(value); + self + } + + pub fn descending(mut self, value: Descending) -> Self { + self.descending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsMetricsQueryEventsQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`event_types`](PodsMetricsQueryEventsQueryRequestBuilder::event_types) + pub fn build(self) -> Result { + Ok(PodsMetricsQueryEventsQueryRequest { + event_types: self.event_types.ok_or_else(|| BuildError::missing_field("event_types"))?, + start: self.start, + end: self.end, + period: self.period, + limit: self.limit, + descending: self.descending, + }) + } +} + diff --git a/agentmail-types/src/types/pods_metrics_query_usage_query_request.rs b/agentmail-types/src/types/pods_metrics_query_usage_query_request.rs new file mode 100644 index 0000000..c3d5013 --- /dev/null +++ b/agentmail-types/src/types/pods_metrics_query_usage_query_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for query-usage +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsMetricsQueryUsageQueryRequest { + #[serde(default)] + pub usage_types: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub descending: Option, +} + +impl PodsMetricsQueryUsageQueryRequest { + pub fn builder() -> PodsMetricsQueryUsageQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsMetricsQueryUsageQueryRequestBuilder { + usage_types: Option>>, + start: Option, + end: Option, + period: Option, + limit: Option, + descending: Option, +} + +impl PodsMetricsQueryUsageQueryRequestBuilder { + pub fn usage_types(mut self, value: Vec>) -> Self { + self.usage_types = Some(value); + self + } + + pub fn start(mut self, value: Start) -> Self { + self.start = Some(value); + self + } + + pub fn end(mut self, value: End) -> Self { + self.end = Some(value); + self + } + + pub fn period(mut self, value: Period) -> Self { + self.period = Some(value); + self + } + + pub fn limit(mut self, value: MetricLimit) -> Self { + self.limit = Some(value); + self + } + + pub fn descending(mut self, value: Descending) -> Self { + self.descending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsMetricsQueryUsageQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`usage_types`](PodsMetricsQueryUsageQueryRequestBuilder::usage_types) + pub fn build(self) -> Result { + Ok(PodsMetricsQueryUsageQueryRequest { + usage_types: self.usage_types.ok_or_else(|| BuildError::missing_field("usage_types"))?, + start: self.start, + end: self.end, + period: self.period, + limit: self.limit, + descending: self.descending, + }) + } +} + diff --git a/agentmail-types/src/types/pods_name.rs b/agentmail-types/src/types/pods_name.rs new file mode 100644 index 0000000..b657c30 --- /dev/null +++ b/agentmail-types/src/types/pods_name.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct PodsName(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/pods_pod.rs b/agentmail-types/src/types/pods_pod.rs new file mode 100644 index 0000000..3d818eb --- /dev/null +++ b/agentmail-types/src/types/pods_pod.rs @@ -0,0 +1,80 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsPod { + #[serde(default)] + pub pod_id: PodsPodId, + #[serde(default)] + pub name: PodsName, + /// Time at which pod was last updated. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, + /// Time at which pod was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +impl PodsPod { + pub fn builder() -> PodsPodBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsPodBuilder { + pod_id: Option, + name: Option, + updated_at: Option>, + created_at: Option>, + client_id: Option, +} + +impl PodsPodBuilder { + pub fn pod_id(mut self, value: PodsPodId) -> Self { + self.pod_id = Some(value); + self + } + + pub fn name(mut self, value: PodsName) -> Self { + self.name = Some(value); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + pub fn client_id(mut self, value: PodsClientId) -> Self { + self.client_id = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsPod`]. + /// This method will fail if any of the following fields are not set: + /// - [`pod_id`](PodsPodBuilder::pod_id) + /// - [`name`](PodsPodBuilder::name) + /// - [`updated_at`](PodsPodBuilder::updated_at) + /// - [`created_at`](PodsPodBuilder::created_at) + pub fn build(self) -> Result { + Ok(PodsPod { + pod_id: self.pod_id.ok_or_else(|| BuildError::missing_field("pod_id"))?, + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + client_id: self.client_id, + }) + } +} diff --git a/agentmail-types/src/types/pods_pod_id.rs b/agentmail-types/src/types/pods_pod_id.rs new file mode 100644 index 0000000..7b70f78 --- /dev/null +++ b/agentmail-types/src/types/pods_pod_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct PodsPodId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/pods_threads_list_query_request.rs b/agentmail-types/src/types/pods_threads_list_query_request.rs new file mode 100644 index 0000000..388a9de --- /dev/null +++ b/agentmail-types/src/types/pods_threads_list_query_request.rs @@ -0,0 +1,150 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsThreadsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_spam: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_blocked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_unauthenticated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_trash: Option, + /// Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub senders: Option>, + /// Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients: Option>, + /// Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, +} + +impl PodsThreadsListQueryRequest { + pub fn builder() -> PodsThreadsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsThreadsListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, + include_spam: Option, + include_blocked: Option, + include_unauthenticated: Option, + include_trash: Option, + senders: Option>, + recipients: Option>, + subject: Option>, +} + +impl PodsThreadsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + pub fn include_spam(mut self, value: IncludeSpam) -> Self { + self.include_spam = Some(value); + self + } + + pub fn include_blocked(mut self, value: IncludeBlocked) -> Self { + self.include_blocked = Some(value); + self + } + + pub fn include_unauthenticated(mut self, value: IncludeUnauthenticated) -> Self { + self.include_unauthenticated = Some(value); + self + } + + pub fn include_trash(mut self, value: IncludeTrash) -> Self { + self.include_trash = Some(value); + self + } + + pub fn senders(mut self, value: Vec) -> Self { + self.senders = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: Vec) -> Self { + self.subject = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsThreadsListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](PodsThreadsListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(PodsThreadsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + include_spam: self.include_spam, + include_blocked: self.include_blocked, + include_unauthenticated: self.include_unauthenticated, + include_trash: self.include_trash, + senders: self.senders, + recipients: self.recipients, + subject: self.subject, + }) + } +} + diff --git a/agentmail-types/src/types/pods_threads_search_query_request.rs b/agentmail-types/src/types/pods_threads_search_query_request.rs new file mode 100644 index 0000000..6d899dc --- /dev/null +++ b/agentmail-types/src/types/pods_threads_search_query_request.rs @@ -0,0 +1,75 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for search +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsThreadsSearchQueryRequest { + #[serde(default)] + pub q: Query, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +impl PodsThreadsSearchQueryRequest { + pub fn builder() -> PodsThreadsSearchQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsThreadsSearchQueryRequestBuilder { + q: Option, + limit: Option, + page_token: Option, + before: Option, + after: Option, +} + +impl PodsThreadsSearchQueryRequestBuilder { + pub fn q(mut self, value: Query) -> Self { + self.q = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsThreadsSearchQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`q`](PodsThreadsSearchQueryRequestBuilder::q) + pub fn build(self) -> Result { + Ok(PodsThreadsSearchQueryRequest { + q: self.q.ok_or_else(|| BuildError::missing_field("q"))?, + limit: self.limit, + page_token: self.page_token, + before: self.before, + after: self.after, + }) + } +} + diff --git a/agentmail-types/src/types/pods_webhooks_list_query_request.rs b/agentmail-types/src/types/pods_webhooks_list_query_request.rs new file mode 100644 index 0000000..a0246da --- /dev/null +++ b/agentmail-types/src/types/pods_webhooks_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct PodsWebhooksListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl PodsWebhooksListQueryRequest { + pub fn builder() -> PodsWebhooksListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PodsWebhooksListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl PodsWebhooksListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`PodsWebhooksListQueryRequest`]. + pub fn build(self) -> Result { + Ok(PodsWebhooksListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/prefix.rs b/agentmail-types/src/types/prefix.rs new file mode 100644 index 0000000..83eef0c --- /dev/null +++ b/agentmail-types/src/types/prefix.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Prefix(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/public_jwk.rs b/agentmail-types/src/types/public_jwk.rs new file mode 100644 index 0000000..98f6f66 --- /dev/null +++ b/agentmail-types/src/types/public_jwk.rs @@ -0,0 +1,69 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// A public P-256 JWK. The object accepts exactly `kty`, `crv`, `x`, and `y`. +/// Private key material such as `d`, embedded key IDs, and all other members +/// are rejected. The server also rejects coordinates that are not a point on +/// P-256. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PublicJwk { + pub kty: PublicJwkKty, + pub crv: PublicJwkCrv, + #[serde(default)] + pub x: PublicJwkCoordinate, + #[serde(default)] + pub y: PublicJwkCoordinate, +} + +impl PublicJwk { + pub fn builder() -> PublicJwkBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PublicJwkBuilder { + kty: Option, + crv: Option, + x: Option, + y: Option, +} + +impl PublicJwkBuilder { + pub fn kty(mut self, value: PublicJwkKty) -> Self { + self.kty = Some(value); + self + } + + pub fn crv(mut self, value: PublicJwkCrv) -> Self { + self.crv = Some(value); + self + } + + pub fn x(mut self, value: PublicJwkCoordinate) -> Self { + self.x = Some(value); + self + } + + pub fn y(mut self, value: PublicJwkCoordinate) -> Self { + self.y = Some(value); + self + } + + /// Consumes the builder and constructs a [`PublicJwk`]. + /// This method will fail if any of the following fields are not set: + /// - [`kty`](PublicJwkBuilder::kty) + /// - [`crv`](PublicJwkBuilder::crv) + /// - [`x`](PublicJwkBuilder::x) + /// - [`y`](PublicJwkBuilder::y) + pub fn build(self) -> Result { + Ok(PublicJwk { + kty: self.kty.ok_or_else(|| BuildError::missing_field("kty"))?, + crv: self.crv.ok_or_else(|| BuildError::missing_field("crv"))?, + x: self.x.ok_or_else(|| BuildError::missing_field("x"))?, + y: self.y.ok_or_else(|| BuildError::missing_field("y"))?, + }) + } +} diff --git a/agentmail-types/src/types/public_jwk_coordinate.rs b/agentmail-types/src/types/public_jwk_coordinate.rs new file mode 100644 index 0000000..bed2a4b --- /dev/null +++ b/agentmail-types/src/types/public_jwk_coordinate.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct PublicJwkCoordinate(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/public_jwk_crv.rs b/agentmail-types/src/types/public_jwk_crv.rs new file mode 100644 index 0000000..ae31d07 --- /dev/null +++ b/agentmail-types/src/types/public_jwk_crv.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PublicJwkCrv { + #[serde(rename = "P-256")] + P256, +} +impl fmt::Display for PublicJwkCrv { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::P256 => "P-256", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/public_jwk_kty.rs b/agentmail-types/src/types/public_jwk_kty.rs new file mode 100644 index 0000000..0f3ffca --- /dev/null +++ b/agentmail-types/src/types/public_jwk_kty.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PublicJwkKty { + #[serde(rename = "EC")] + Ec, +} +impl fmt::Display for PublicJwkKty { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Ec => "EC", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/public_key_credential.rs b/agentmail-types/src/types/public_key_credential.rs new file mode 100644 index 0000000..550c1d2 --- /dev/null +++ b/agentmail-types/src/types/public_key_credential.rs @@ -0,0 +1,122 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// An AgentID sign-in credential. `type` and `api_key_id` are server-owned; +/// use `api_key_id` as the JWS `kid`. This response never contains a bearer +/// secret or private key. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PublicKeyCredential { + /// Server-generated credential ID. Store this value as the signing key's `kid`. + #[serde(default)] + pub api_key_id: String, + /// Server-owned credential discriminator. Callers cannot select or update it. + pub r#type: PublicKeyCredentialType, + /// Human-readable credential name. + #[serde(default)] + pub name: Name, + pub public_key: PublicKeyMaterial, + pub scope: PublicKeyScope, + /// Immutable absolute expiry. Omitted when the credential does not expire. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + /// Present when organization-wide revoke-all invalidated this credential generation. + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked_at: Option>, + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, +} + +impl PublicKeyCredential { + pub fn builder() -> PublicKeyCredentialBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PublicKeyCredentialBuilder { + api_key_id: Option, + r#type: Option, + name: Option, + public_key: Option, + scope: Option, + expires_at: Option>, + revoked_at: Option>, + created_at: Option>, + updated_at: Option>, +} + +impl PublicKeyCredentialBuilder { + pub fn api_key_id(mut self, value: impl Into) -> Self { + self.api_key_id = Some(value.into()); + self + } + + pub fn r#type(mut self, value: PublicKeyCredentialType) -> Self { + self.r#type = Some(value); + self + } + + pub fn name(mut self, value: Name) -> Self { + self.name = Some(value); + self + } + + pub fn public_key(mut self, value: PublicKeyMaterial) -> Self { + self.public_key = Some(value); + self + } + + pub fn scope(mut self, value: PublicKeyScope) -> Self { + self.scope = Some(value); + self + } + + pub fn expires_at(mut self, value: DateTime) -> Self { + self.expires_at = Some(value); + self + } + + pub fn revoked_at(mut self, value: DateTime) -> Self { + self.revoked_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`PublicKeyCredential`]. + /// This method will fail if any of the following fields are not set: + /// - [`api_key_id`](PublicKeyCredentialBuilder::api_key_id) + /// - [`r#type`](PublicKeyCredentialBuilder::r#type) + /// - [`name`](PublicKeyCredentialBuilder::name) + /// - [`public_key`](PublicKeyCredentialBuilder::public_key) + /// - [`scope`](PublicKeyCredentialBuilder::scope) + /// - [`created_at`](PublicKeyCredentialBuilder::created_at) + /// - [`updated_at`](PublicKeyCredentialBuilder::updated_at) + pub fn build(self) -> Result { + Ok(PublicKeyCredential { + api_key_id: self.api_key_id.ok_or_else(|| BuildError::missing_field("api_key_id"))?, + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + public_key: self.public_key.ok_or_else(|| BuildError::missing_field("public_key"))?, + scope: self.scope.ok_or_else(|| BuildError::missing_field("scope"))?, + expires_at: self.expires_at, + revoked_at: self.revoked_at, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/public_key_credential_type.rs b/agentmail-types/src/types/public_key_credential_type.rs new file mode 100644 index 0000000..d7ae4ac --- /dev/null +++ b/agentmail-types/src/types/public_key_credential_type.rs @@ -0,0 +1,18 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Server-owned credential discriminator. Callers cannot select or update it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PublicKeyCredentialType { + #[serde(rename = "public_key")] + PublicKey, +} +impl fmt::Display for PublicKeyCredentialType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::PublicKey => "public_key", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/public_key_material.rs b/agentmail-types/src/types/public_key_material.rs new file mode 100644 index 0000000..07a989c --- /dev/null +++ b/agentmail-types/src/types/public_key_material.rs @@ -0,0 +1,48 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Registered public key material and its server-computed RFC 7638 thumbprint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PublicKeyMaterial { + pub jwk: PublicJwk, + /// RFC 7638 SHA-256 JWK thumbprint encoded as unpadded base64url. + #[serde(default)] + pub fingerprint: String, +} + +impl PublicKeyMaterial { + pub fn builder() -> PublicKeyMaterialBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PublicKeyMaterialBuilder { + jwk: Option, + fingerprint: Option, +} + +impl PublicKeyMaterialBuilder { + pub fn jwk(mut self, value: PublicJwk) -> Self { + self.jwk = Some(value); + self + } + + pub fn fingerprint(mut self, value: impl Into) -> Self { + self.fingerprint = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`PublicKeyMaterial`]. + /// This method will fail if any of the following fields are not set: + /// - [`jwk`](PublicKeyMaterialBuilder::jwk) + /// - [`fingerprint`](PublicKeyMaterialBuilder::fingerprint) + pub fn build(self) -> Result { + Ok(PublicKeyMaterial { + jwk: self.jwk.ok_or_else(|| BuildError::missing_field("jwk"))?, + fingerprint: self.fingerprint.ok_or_else(|| BuildError::missing_field("fingerprint"))?, + }) + } +} diff --git a/agentmail-types/src/types/public_key_scope.rs b/agentmail-types/src/types/public_key_scope.rs new file mode 100644 index 0000000..b7bfbaa --- /dev/null +++ b/agentmail-types/src/types/public_key_scope.rs @@ -0,0 +1,80 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum PublicKeyScope { + PublicKeyScopeZero(PublicKeyScopeZero), + + PublicKeyScopeOne(PublicKeyScopeOne), + + PublicKeyScopeTwo(PublicKeyScopeTwo), +} + +impl PublicKeyScope { + pub fn is_public_key_scope_zero(&self) -> bool { + matches!(self, Self::PublicKeyScopeZero(_)) + } + + pub fn is_public_key_scope_one(&self) -> bool { + matches!(self, Self::PublicKeyScopeOne(_)) + } + + pub fn is_public_key_scope_two(&self) -> bool { + matches!(self, Self::PublicKeyScopeTwo(_)) + } + + + pub fn as_public_key_scope_zero(&self) -> Option<&PublicKeyScopeZero> { + match self { + Self::PublicKeyScopeZero(value) => Some(value), + _ => None, + } + } + + pub fn into_public_key_scope_zero(self) -> Option { + match self { + Self::PublicKeyScopeZero(value) => Some(value), + _ => None, + } + } + + pub fn as_public_key_scope_one(&self) -> Option<&PublicKeyScopeOne> { + match self { + Self::PublicKeyScopeOne(value) => Some(value), + _ => None, + } + } + + pub fn into_public_key_scope_one(self) -> Option { + match self { + Self::PublicKeyScopeOne(value) => Some(value), + _ => None, + } + } + + pub fn as_public_key_scope_two(&self) -> Option<&PublicKeyScopeTwo> { + match self { + Self::PublicKeyScopeTwo(value) => Some(value), + _ => None, + } + } + + pub fn into_public_key_scope_two(self) -> Option { + match self { + Self::PublicKeyScopeTwo(value) => Some(value), + _ => None, + } + } +} + +impl fmt::Display for PublicKeyScope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PublicKeyScopeZero(value) => write!(f, "{}", serde_json::to_string(value).unwrap_or_else(|_| format!("{:?}", value))), + Self::PublicKeyScopeOne(value) => write!(f, "{}", serde_json::to_string(value).unwrap_or_else(|_| format!("{:?}", value))), + Self::PublicKeyScopeTwo(value) => write!(f, "{}", serde_json::to_string(value).unwrap_or_else(|_| format!("{:?}", value))), + } + } +} diff --git a/agentmail-types/src/types/public_key_scope_one.rs b/agentmail-types/src/types/public_key_scope_one.rs new file mode 100644 index 0000000..2bdca2f --- /dev/null +++ b/agentmail-types/src/types/public_key_scope_one.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PublicKeyScopeOne { + #[serde(flatten)] + pub pod_public_key_scope_fields: PodPublicKeyScope, + pub r#type: PublicKeyScopeOneType, +} + +impl PublicKeyScopeOne { + pub fn builder() -> PublicKeyScopeOneBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PublicKeyScopeOneBuilder { + pod_public_key_scope_fields: Option, + r#type: Option, +} + +impl PublicKeyScopeOneBuilder { + pub fn pod_public_key_scope_fields(mut self, value: PodPublicKeyScope) -> Self { + self.pod_public_key_scope_fields = Some(value); + self + } + + pub fn r#type(mut self, value: PublicKeyScopeOneType) -> Self { + self.r#type = Some(value); + self + } + + /// Consumes the builder and constructs a [`PublicKeyScopeOne`]. + /// This method will fail if any of the following fields are not set: + /// - [`pod_public_key_scope_fields`](PublicKeyScopeOneBuilder::pod_public_key_scope_fields) + /// - [`r#type`](PublicKeyScopeOneBuilder::r#type) + pub fn build(self) -> Result { + Ok(PublicKeyScopeOne { + pod_public_key_scope_fields: self.pod_public_key_scope_fields.ok_or_else(|| BuildError::missing_field("pod_public_key_scope_fields"))?, + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + }) + } +} diff --git a/agentmail-types/src/types/public_key_scope_one_type.rs b/agentmail-types/src/types/public_key_scope_one_type.rs new file mode 100644 index 0000000..c4c87bb --- /dev/null +++ b/agentmail-types/src/types/public_key_scope_one_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PublicKeyScopeOneType { + #[serde(rename = "pod")] + Pod, +} +impl fmt::Display for PublicKeyScopeOneType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Pod => "pod", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/public_key_scope_two.rs b/agentmail-types/src/types/public_key_scope_two.rs new file mode 100644 index 0000000..60419a8 --- /dev/null +++ b/agentmail-types/src/types/public_key_scope_two.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PublicKeyScopeTwo { + #[serde(flatten)] + pub inbox_public_key_scope_fields: InboxPublicKeyScope, + pub r#type: PublicKeyScopeTwoType, +} + +impl PublicKeyScopeTwo { + pub fn builder() -> PublicKeyScopeTwoBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PublicKeyScopeTwoBuilder { + inbox_public_key_scope_fields: Option, + r#type: Option, +} + +impl PublicKeyScopeTwoBuilder { + pub fn inbox_public_key_scope_fields(mut self, value: InboxPublicKeyScope) -> Self { + self.inbox_public_key_scope_fields = Some(value); + self + } + + pub fn r#type(mut self, value: PublicKeyScopeTwoType) -> Self { + self.r#type = Some(value); + self + } + + /// Consumes the builder and constructs a [`PublicKeyScopeTwo`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_public_key_scope_fields`](PublicKeyScopeTwoBuilder::inbox_public_key_scope_fields) + /// - [`r#type`](PublicKeyScopeTwoBuilder::r#type) + pub fn build(self) -> Result { + Ok(PublicKeyScopeTwo { + inbox_public_key_scope_fields: self.inbox_public_key_scope_fields.ok_or_else(|| BuildError::missing_field("inbox_public_key_scope_fields"))?, + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + }) + } +} diff --git a/agentmail-types/src/types/public_key_scope_two_type.rs b/agentmail-types/src/types/public_key_scope_two_type.rs new file mode 100644 index 0000000..240fcaa --- /dev/null +++ b/agentmail-types/src/types/public_key_scope_two_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PublicKeyScopeTwoType { + #[serde(rename = "inbox")] + Inbox, +} +impl fmt::Display for PublicKeyScopeTwoType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Inbox => "inbox", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/public_key_scope_zero.rs b/agentmail-types/src/types/public_key_scope_zero.rs new file mode 100644 index 0000000..ede6461 --- /dev/null +++ b/agentmail-types/src/types/public_key_scope_zero.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct PublicKeyScopeZero { + #[serde(flatten)] + pub organization_public_key_scope_fields: OrganizationPublicKeyScope, + pub r#type: PublicKeyScopeZeroType, +} + +impl PublicKeyScopeZero { + pub fn builder() -> PublicKeyScopeZeroBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct PublicKeyScopeZeroBuilder { + organization_public_key_scope_fields: Option, + r#type: Option, +} + +impl PublicKeyScopeZeroBuilder { + pub fn organization_public_key_scope_fields(mut self, value: OrganizationPublicKeyScope) -> Self { + self.organization_public_key_scope_fields = Some(value); + self + } + + pub fn r#type(mut self, value: PublicKeyScopeZeroType) -> Self { + self.r#type = Some(value); + self + } + + /// Consumes the builder and constructs a [`PublicKeyScopeZero`]. + /// This method will fail if any of the following fields are not set: + /// - [`organization_public_key_scope_fields`](PublicKeyScopeZeroBuilder::organization_public_key_scope_fields) + /// - [`r#type`](PublicKeyScopeZeroBuilder::r#type) + pub fn build(self) -> Result { + Ok(PublicKeyScopeZero { + organization_public_key_scope_fields: self.organization_public_key_scope_fields.ok_or_else(|| BuildError::missing_field("organization_public_key_scope_fields"))?, + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + }) + } +} diff --git a/agentmail-types/src/types/public_key_scope_zero_type.rs b/agentmail-types/src/types/public_key_scope_zero_type.rs new file mode 100644 index 0000000..6749306 --- /dev/null +++ b/agentmail-types/src/types/public_key_scope_zero_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum PublicKeyScopeZeroType { + #[serde(rename = "organization")] + Organization, +} +impl fmt::Display for PublicKeyScopeZeroType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Organization => "organization", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/query.rs b/agentmail-types/src/types/query.rs new file mode 100644 index 0000000..ace4baf --- /dev/null +++ b/agentmail-types/src/types/query.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Query(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/query_metrics_response.rs b/agentmail-types/src/types/query_metrics_response.rs new file mode 100644 index 0000000..544ed34 --- /dev/null +++ b/agentmail-types/src/types/query_metrics_response.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct QueryMetricsResponse(pub HashMap>); \ No newline at end of file diff --git a/agentmail-types/src/types/query_usage_response.rs b/agentmail-types/src/types/query_usage_response.rs new file mode 100644 index 0000000..46103d0 --- /dev/null +++ b/agentmail-types/src/types/query_usage_response.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct QueryUsageResponse(pub HashMap>); \ No newline at end of file diff --git a/agentmail-types/src/types/raw_message_response.rs b/agentmail-types/src/types/raw_message_response.rs new file mode 100644 index 0000000..1ecfaa1 --- /dev/null +++ b/agentmail-types/src/types/raw_message_response.rs @@ -0,0 +1,73 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// S3 presigned URL to download the raw .eml file. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct RawMessageResponse { + /// ID of the message. + #[serde(default)] + pub message_id: MessageId, + /// Size of the raw message in bytes. + #[serde(default)] + pub size: MessageSize, + /// S3 presigned URL to download the raw message. Expires at expires_at. + #[serde(default)] + pub download_url: String, + /// Time at which the download URL expires. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub expires_at: DateTime, +} + +impl RawMessageResponse { + pub fn builder() -> RawMessageResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct RawMessageResponseBuilder { + message_id: Option, + size: Option, + download_url: Option, + expires_at: Option>, +} + +impl RawMessageResponseBuilder { + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn size(mut self, value: MessageSize) -> Self { + self.size = Some(value); + self + } + + pub fn download_url(mut self, value: impl Into) -> Self { + self.download_url = Some(value.into()); + self + } + + pub fn expires_at(mut self, value: DateTime) -> Self { + self.expires_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`RawMessageResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`message_id`](RawMessageResponseBuilder::message_id) + /// - [`size`](RawMessageResponseBuilder::size) + /// - [`download_url`](RawMessageResponseBuilder::download_url) + /// - [`expires_at`](RawMessageResponseBuilder::expires_at) + pub fn build(self) -> Result { + Ok(RawMessageResponse { + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + download_url: self.download_url.ok_or_else(|| BuildError::missing_field("download_url"))?, + expires_at: self.expires_at.ok_or_else(|| BuildError::missing_field("expires_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/recipient.rs b/agentmail-types/src/types/recipient.rs new file mode 100644 index 0000000..e2f10d1 --- /dev/null +++ b/agentmail-types/src/types/recipient.rs @@ -0,0 +1,49 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Recipient { + /// Recipient address. + #[serde(default)] + pub address: String, + /// Recipient status. + #[serde(default)] + pub status: String, +} + +impl Recipient { + pub fn builder() -> RecipientBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct RecipientBuilder { + address: Option, + status: Option, +} + +impl RecipientBuilder { + pub fn address(mut self, value: impl Into) -> Self { + self.address = Some(value.into()); + self + } + + pub fn status(mut self, value: impl Into) -> Self { + self.status = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`Recipient`]. + /// This method will fail if any of the following fields are not set: + /// - [`address`](RecipientBuilder::address) + /// - [`status`](RecipientBuilder::status) + pub fn build(self) -> Result { + Ok(Recipient { + address: self.address.ok_or_else(|| BuildError::missing_field("address"))?, + status: self.status.ok_or_else(|| BuildError::missing_field("status"))?, + }) + } +} diff --git a/agentmail-types/src/types/record_status.rs b/agentmail-types/src/types/record_status.rs new file mode 100644 index 0000000..c4a699c --- /dev/null +++ b/agentmail-types/src/types/record_status.rs @@ -0,0 +1,48 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RecordStatus { + Missing, + Invalid, + Valid, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for RecordStatus { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Missing => serializer.serialize_str("MISSING"), + Self::Invalid => serializer.serialize_str("INVALID"), + Self::Valid => serializer.serialize_str("VALID"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for RecordStatus { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "MISSING" => Ok(Self::Missing), + "INVALID" => Ok(Self::Invalid), + "VALID" => Ok(Self::Valid), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for RecordStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing => write!(f, "MISSING"), + Self::Invalid => write!(f, "INVALID"), + Self::Valid => write!(f, "VALID"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/record_type.rs b/agentmail-types/src/types/record_type.rs new file mode 100644 index 0000000..1b27f6f --- /dev/null +++ b/agentmail-types/src/types/record_type.rs @@ -0,0 +1,48 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RecordType { + Txt, + Cname, + Mx, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for RecordType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Txt => serializer.serialize_str("TXT"), + Self::Cname => serializer.serialize_str("CNAME"), + Self::Mx => serializer.serialize_str("MX"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for RecordType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "TXT" => Ok(Self::Txt), + "CNAME" => Ok(Self::Cname), + "MX" => Ok(Self::Mx), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for RecordType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Txt => write!(f, "TXT"), + Self::Cname => write!(f, "CNAME"), + Self::Mx => write!(f, "MX"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/reject.rs b/agentmail-types/src/types/reject.rs new file mode 100644 index 0000000..259b334 --- /dev/null +++ b/agentmail-types/src/types/reject.rs @@ -0,0 +1,78 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct Reject { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub timestamp: Timestamp, + /// Reject reason. + #[serde(default)] + pub reason: String, +} + +impl Reject { + pub fn builder() -> RejectBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct RejectBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + timestamp: Option, + reason: Option, +} + +impl RejectBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn timestamp(mut self, value: Timestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn reason(mut self, value: impl Into) -> Self { + self.reason = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`Reject`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](RejectBuilder::inbox_id) + /// - [`thread_id`](RejectBuilder::thread_id) + /// - [`message_id`](RejectBuilder::message_id) + /// - [`timestamp`](RejectBuilder::timestamp) + /// - [`reason`](RejectBuilder::reason) + pub fn build(self) -> Result { + Ok(Reject { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + reason: self.reason.ok_or_else(|| BuildError::missing_field("reason"))?, + }) + } +} diff --git a/agentmail-types/src/types/reply_all.rs b/agentmail-types/src/types/reply_all.rs new file mode 100644 index 0000000..ca16e38 --- /dev/null +++ b/agentmail-types/src/types/reply_all.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ReplyAll(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/reply_all_message_request.rs b/agentmail-types/src/types/reply_all_message_request.rs new file mode 100644 index 0000000..aa027b6 --- /dev/null +++ b/agentmail-types/src/types/reply_all_message_request.rs @@ -0,0 +1,90 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct ReplyAllMessageRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_opens: Option, +} + +impl ReplyAllMessageRequest { + pub fn builder() -> ReplyAllMessageRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ReplyAllMessageRequestBuilder { + labels: Option, + reply_to: Option, + text: Option, + html: Option, + attachments: Option, + headers: Option, + track_opens: Option, +} + +impl ReplyAllMessageRequestBuilder { + pub fn labels(mut self, value: MessageLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn reply_to(mut self, value: SendMessageReplyTo) -> Self { + self.reply_to = Some(value); + self + } + + pub fn text(mut self, value: MessageText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: MessageHtml) -> Self { + self.html = Some(value); + self + } + + pub fn attachments(mut self, value: SendMessageAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn headers(mut self, value: SendMessageHeaders) -> Self { + self.headers = Some(value); + self + } + + pub fn track_opens(mut self, value: TrackOpens) -> Self { + self.track_opens = Some(value); + self + } + + /// Consumes the builder and constructs a [`ReplyAllMessageRequest`]. + pub fn build(self) -> Result { + Ok(ReplyAllMessageRequest { + labels: self.labels, + reply_to: self.reply_to, + text: self.text, + html: self.html, + attachments: self.attachments, + headers: self.headers, + track_opens: self.track_opens, + }) + } +} + diff --git a/agentmail-types/src/types/reply_to_message_request.rs b/agentmail-types/src/types/reply_to_message_request.rs new file mode 100644 index 0000000..2a06169 --- /dev/null +++ b/agentmail-types/src/types/reply_to_message_request.rs @@ -0,0 +1,126 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct ReplyToMessageRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_all: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_opens: Option, +} + +impl ReplyToMessageRequest { + pub fn builder() -> ReplyToMessageRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ReplyToMessageRequestBuilder { + labels: Option, + reply_to: Option, + to: Option, + cc: Option, + bcc: Option, + reply_all: Option, + text: Option, + html: Option, + attachments: Option, + headers: Option, + track_opens: Option, +} + +impl ReplyToMessageRequestBuilder { + pub fn labels(mut self, value: MessageLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn reply_to(mut self, value: SendMessageReplyTo) -> Self { + self.reply_to = Some(value); + self + } + + pub fn to(mut self, value: SendMessageTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: SendMessageCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: SendMessageBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn reply_all(mut self, value: ReplyAll) -> Self { + self.reply_all = Some(value); + self + } + + pub fn text(mut self, value: MessageText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: MessageHtml) -> Self { + self.html = Some(value); + self + } + + pub fn attachments(mut self, value: SendMessageAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn headers(mut self, value: SendMessageHeaders) -> Self { + self.headers = Some(value); + self + } + + pub fn track_opens(mut self, value: TrackOpens) -> Self { + self.track_opens = Some(value); + self + } + + /// Consumes the builder and constructs a [`ReplyToMessageRequest`]. + pub fn build(self) -> Result { + Ok(ReplyToMessageRequest { + labels: self.labels, + reply_to: self.reply_to, + to: self.to, + cc: self.cc, + bcc: self.bcc, + reply_all: self.reply_all, + text: self.text, + html: self.html, + attachments: self.attachments, + headers: self.headers, + track_opens: self.track_opens, + }) + } +} + diff --git a/agentmail-types/src/types/revoke_all_agent_id_sign_in_keys_response.rs b/agentmail-types/src/types/revoke_all_agent_id_sign_in_keys_response.rs new file mode 100644 index 0000000..b10095b --- /dev/null +++ b/agentmail-types/src/types/revoke_all_agent_id_sign_in_keys_response.rs @@ -0,0 +1,59 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Permanent idempotency receipt for an organization-wide AgentID sign-in key revocation. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct RevokeAllAgentIdSignInKeysResponse { + #[serde(default)] + pub previous_generation: i64, + #[serde(default)] + pub current_generation: i64, + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub revoked_at: DateTime, +} + +impl RevokeAllAgentIdSignInKeysResponse { + pub fn builder() -> RevokeAllAgentIdSignInKeysResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct RevokeAllAgentIdSignInKeysResponseBuilder { + previous_generation: Option, + current_generation: Option, + revoked_at: Option>, +} + +impl RevokeAllAgentIdSignInKeysResponseBuilder { + pub fn previous_generation(mut self, value: i64) -> Self { + self.previous_generation = Some(value); + self + } + + pub fn current_generation(mut self, value: i64) -> Self { + self.current_generation = Some(value); + self + } + + pub fn revoked_at(mut self, value: DateTime) -> Self { + self.revoked_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`RevokeAllAgentIdSignInKeysResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`previous_generation`](RevokeAllAgentIdSignInKeysResponseBuilder::previous_generation) + /// - [`current_generation`](RevokeAllAgentIdSignInKeysResponseBuilder::current_generation) + /// - [`revoked_at`](RevokeAllAgentIdSignInKeysResponseBuilder::revoked_at) + pub fn build(self) -> Result { + Ok(RevokeAllAgentIdSignInKeysResponse { + previous_generation: self.previous_generation.ok_or_else(|| BuildError::missing_field("previous_generation"))?, + current_generation: self.current_generation.ok_or_else(|| BuildError::missing_field("current_generation"))?, + revoked_at: self.revoked_at.ok_or_else(|| BuildError::missing_field("revoked_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/scope_type.rs b/agentmail-types/src/types/scope_type.rs new file mode 100644 index 0000000..0029c65 --- /dev/null +++ b/agentmail-types/src/types/scope_type.rs @@ -0,0 +1,49 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// The scope tier the authenticated credential is bound to. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ScopeType { + Organization, + Pod, + Inbox, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for ScopeType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Organization => serializer.serialize_str("organization"), + Self::Pod => serializer.serialize_str("pod"), + Self::Inbox => serializer.serialize_str("inbox"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for ScopeType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "organization" => Ok(Self::Organization), + "pod" => Ok(Self::Pod), + "inbox" => Ok(Self::Inbox), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for ScopeType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Organization => write!(f, "organization"), + Self::Pod => write!(f, "pod"), + Self::Inbox => write!(f, "inbox"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/search_message_highlights.rs b/agentmail-types/src/types/search_message_highlights.rs new file mode 100644 index 0000000..32a22d7 --- /dev/null +++ b/agentmail-types/src/types/search_message_highlights.rs @@ -0,0 +1,69 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Matched fragments per field on a message search result, with matched terms +/// wrapped in `**`. A field key is present only when the query matched that +/// field, so the present keys also tell you which fields produced the hit. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SearchMessageHighlights { + /// Matched fragments from the sender address. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option>, + /// Matched fragments from the recipient addresses (to, cc, or bcc). + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients: Option>, + /// Matched fragments from the subject. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, + /// Matched fragments from the message body. + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option>, +} + +impl SearchMessageHighlights { + pub fn builder() -> SearchMessageHighlightsBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SearchMessageHighlightsBuilder { + from: Option>, + recipients: Option>, + subject: Option>, + text: Option>, +} + +impl SearchMessageHighlightsBuilder { + pub fn from(mut self, value: Vec) -> Self { + self.from = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: Vec) -> Self { + self.subject = Some(value); + self + } + + pub fn text(mut self, value: Vec) -> Self { + self.text = Some(value); + self + } + + /// Consumes the builder and constructs a [`SearchMessageHighlights`]. + pub fn build(self) -> Result { + Ok(SearchMessageHighlights { + from: self.from, + recipients: self.recipients, + subject: self.subject, + text: self.text, + }) + } +} diff --git a/agentmail-types/src/types/search_message_item.rs b/agentmail-types/src/types/search_message_item.rs new file mode 100644 index 0000000..5339d91 --- /dev/null +++ b/agentmail-types/src/types/search_message_item.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SearchMessageItem { + #[serde(flatten)] + pub message_item_fields: MessageItem, + /// Matched fragments per field. Present only when the query matched an indexed field. + #[serde(skip_serializing_if = "Option::is_none")] + pub highlights: Option, +} + +impl SearchMessageItem { + pub fn builder() -> SearchMessageItemBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SearchMessageItemBuilder { + message_item_fields: Option, + highlights: Option, +} + +impl SearchMessageItemBuilder { + pub fn message_item_fields(mut self, value: MessageItem) -> Self { + self.message_item_fields = Some(value); + self + } + + pub fn highlights(mut self, value: SearchMessageHighlights) -> Self { + self.highlights = Some(value); + self + } + + /// Consumes the builder and constructs a [`SearchMessageItem`]. + /// This method will fail if any of the following fields are not set: + /// - [`message_item_fields`](SearchMessageItemBuilder::message_item_fields) + pub fn build(self) -> Result { + Ok(SearchMessageItem { + message_item_fields: self.message_item_fields.ok_or_else(|| BuildError::missing_field("message_item_fields"))?, + highlights: self.highlights, + }) + } +} diff --git a/agentmail-types/src/types/search_messages_response.rs b/agentmail-types/src/types/search_messages_response.rs new file mode 100644 index 0000000..a05eb9f --- /dev/null +++ b/agentmail-types/src/types/search_messages_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SearchMessagesResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by relevance, best match first. + #[serde(default)] + pub messages: Vec, +} + +impl SearchMessagesResponse { + pub fn builder() -> SearchMessagesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SearchMessagesResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + messages: Option>, +} + +impl SearchMessagesResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn messages(mut self, value: Vec) -> Self { + self.messages = Some(value); + self + } + + /// Consumes the builder and constructs a [`SearchMessagesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](SearchMessagesResponseBuilder::count) + /// - [`messages`](SearchMessagesResponseBuilder::messages) + pub fn build(self) -> Result { + Ok(SearchMessagesResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + messages: self.messages.ok_or_else(|| BuildError::missing_field("messages"))?, + }) + } +} diff --git a/agentmail-types/src/types/search_thread_highlights.rs b/agentmail-types/src/types/search_thread_highlights.rs new file mode 100644 index 0000000..af3e7c5 --- /dev/null +++ b/agentmail-types/src/types/search_thread_highlights.rs @@ -0,0 +1,69 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Matched fragments per field on a thread search result, with matched terms +/// wrapped in `**`. A field key is present only when the query matched that +/// field, so the present keys also tell you which fields produced the hit. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SearchThreadHighlights { + /// Matched fragments from a sender address in the thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub from: Option>, + /// Matched fragments from a recipient address in the thread (to, cc, or bcc). + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients: Option>, + /// Matched fragments from the subject. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, + /// Matched fragments from a message body in the thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option>, +} + +impl SearchThreadHighlights { + pub fn builder() -> SearchThreadHighlightsBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SearchThreadHighlightsBuilder { + from: Option>, + recipients: Option>, + subject: Option>, + text: Option>, +} + +impl SearchThreadHighlightsBuilder { + pub fn from(mut self, value: Vec) -> Self { + self.from = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: Vec) -> Self { + self.subject = Some(value); + self + } + + pub fn text(mut self, value: Vec) -> Self { + self.text = Some(value); + self + } + + /// Consumes the builder and constructs a [`SearchThreadHighlights`]. + pub fn build(self) -> Result { + Ok(SearchThreadHighlights { + from: self.from, + recipients: self.recipients, + subject: self.subject, + text: self.text, + }) + } +} diff --git a/agentmail-types/src/types/search_thread_item.rs b/agentmail-types/src/types/search_thread_item.rs new file mode 100644 index 0000000..0326557 --- /dev/null +++ b/agentmail-types/src/types/search_thread_item.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SearchThreadItem { + #[serde(flatten)] + pub thread_item_fields: ThreadItem, + /// Matched fragments per field. Present only when the query matched an indexed field. + #[serde(skip_serializing_if = "Option::is_none")] + pub highlights: Option, +} + +impl SearchThreadItem { + pub fn builder() -> SearchThreadItemBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SearchThreadItemBuilder { + thread_item_fields: Option, + highlights: Option, +} + +impl SearchThreadItemBuilder { + pub fn thread_item_fields(mut self, value: ThreadItem) -> Self { + self.thread_item_fields = Some(value); + self + } + + pub fn highlights(mut self, value: SearchThreadHighlights) -> Self { + self.highlights = Some(value); + self + } + + /// Consumes the builder and constructs a [`SearchThreadItem`]. + /// This method will fail if any of the following fields are not set: + /// - [`thread_item_fields`](SearchThreadItemBuilder::thread_item_fields) + pub fn build(self) -> Result { + Ok(SearchThreadItem { + thread_item_fields: self.thread_item_fields.ok_or_else(|| BuildError::missing_field("thread_item_fields"))?, + highlights: self.highlights, + }) + } +} diff --git a/agentmail-types/src/types/search_threads_response.rs b/agentmail-types/src/types/search_threads_response.rs new file mode 100644 index 0000000..420f5c9 --- /dev/null +++ b/agentmail-types/src/types/search_threads_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SearchThreadsResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by relevance, best match first. + #[serde(default)] + pub threads: Vec, +} + +impl SearchThreadsResponse { + pub fn builder() -> SearchThreadsResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SearchThreadsResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + threads: Option>, +} + +impl SearchThreadsResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn threads(mut self, value: Vec) -> Self { + self.threads = Some(value); + self + } + + /// Consumes the builder and constructs a [`SearchThreadsResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](SearchThreadsResponseBuilder::count) + /// - [`threads`](SearchThreadsResponseBuilder::threads) + pub fn build(self) -> Result { + Ok(SearchThreadsResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + threads: self.threads.ok_or_else(|| BuildError::missing_field("threads"))?, + }) + } +} diff --git a/agentmail-types/src/types/send_attachment.rs b/agentmail-types/src/types/send_attachment.rs new file mode 100644 index 0000000..588c286 --- /dev/null +++ b/agentmail-types/src/types/send_attachment.rs @@ -0,0 +1,82 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SendAttachment { + #[serde(skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_disposition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content_id: Option, + /// Base64 encoded content of attachment. + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + /// URL to the attachment. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} + +impl SendAttachment { + pub fn builder() -> SendAttachmentBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SendAttachmentBuilder { + filename: Option, + content_type: Option, + content_disposition: Option, + content_id: Option, + content: Option, + url: Option, +} + +impl SendAttachmentBuilder { + pub fn filename(mut self, value: AttachmentFilename) -> Self { + self.filename = Some(value); + self + } + + pub fn content_type(mut self, value: AttachmentContentType) -> Self { + self.content_type = Some(value); + self + } + + pub fn content_disposition(mut self, value: AttachmentContentDisposition) -> Self { + self.content_disposition = Some(value); + self + } + + pub fn content_id(mut self, value: AttachmentContentId) -> Self { + self.content_id = Some(value); + self + } + + pub fn content(mut self, value: impl Into) -> Self { + self.content = Some(value.into()); + self + } + + pub fn url(mut self, value: impl Into) -> Self { + self.url = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`SendAttachment`]. + pub fn build(self) -> Result { + Ok(SendAttachment { + filename: self.filename, + content_type: self.content_type, + content_disposition: self.content_disposition, + content_id: self.content_id, + content: self.content, + url: self.url, + }) + } +} diff --git a/agentmail-types/src/types/send_event.rs b/agentmail-types/src/types/send_event.rs new file mode 100644 index 0000000..2e4aa4b --- /dev/null +++ b/agentmail-types/src/types/send_event.rs @@ -0,0 +1,78 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SendEvent { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub timestamp: Timestamp, + /// Sent recipients. + #[serde(default)] + pub recipients: Vec, +} + +impl SendEvent { + pub fn builder() -> SendEventBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SendEventBuilder { + inbox_id: Option, + thread_id: Option, + message_id: Option, + timestamp: Option, + recipients: Option>, +} + +impl SendEventBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn timestamp(mut self, value: Timestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + /// Consumes the builder and constructs a [`SendEvent`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](SendEventBuilder::inbox_id) + /// - [`thread_id`](SendEventBuilder::thread_id) + /// - [`message_id`](SendEventBuilder::message_id) + /// - [`timestamp`](SendEventBuilder::timestamp) + /// - [`recipients`](SendEventBuilder::recipients) + pub fn build(self) -> Result { + Ok(SendEvent { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + recipients: self.recipients.ok_or_else(|| BuildError::missing_field("recipients"))?, + }) + } +} diff --git a/agentmail-types/src/types/send_message_attachments.rs b/agentmail-types/src/types/send_message_attachments.rs new file mode 100644 index 0000000..3c502b9 --- /dev/null +++ b/agentmail-types/src/types/send_message_attachments.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct SendMessageAttachments(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/send_message_bcc.rs b/agentmail-types/src/types/send_message_bcc.rs new file mode 100644 index 0000000..b316660 --- /dev/null +++ b/agentmail-types/src/types/send_message_bcc.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct SendMessageBcc(pub Addresses); \ No newline at end of file diff --git a/agentmail-types/src/types/send_message_cc.rs b/agentmail-types/src/types/send_message_cc.rs new file mode 100644 index 0000000..baf5dcb --- /dev/null +++ b/agentmail-types/src/types/send_message_cc.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct SendMessageCc(pub Addresses); \ No newline at end of file diff --git a/agentmail-types/src/types/send_message_headers.rs b/agentmail-types/src/types/send_message_headers.rs new file mode 100644 index 0000000..1c4d529 --- /dev/null +++ b/agentmail-types/src/types/send_message_headers.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct SendMessageHeaders(pub HashMap); \ No newline at end of file diff --git a/agentmail-types/src/types/send_message_reply_to.rs b/agentmail-types/src/types/send_message_reply_to.rs new file mode 100644 index 0000000..821f4c4 --- /dev/null +++ b/agentmail-types/src/types/send_message_reply_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct SendMessageReplyTo(pub Addresses); \ No newline at end of file diff --git a/agentmail-types/src/types/send_message_request.rs b/agentmail-types/src/types/send_message_request.rs new file mode 100644 index 0000000..f77bae8 --- /dev/null +++ b/agentmail-types/src/types/send_message_request.rs @@ -0,0 +1,125 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SendMessageRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_opens: Option, +} + +impl SendMessageRequest { + pub fn builder() -> SendMessageRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SendMessageRequestBuilder { + labels: Option, + reply_to: Option, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + text: Option, + html: Option, + attachments: Option, + headers: Option, + track_opens: Option, +} + +impl SendMessageRequestBuilder { + pub fn labels(mut self, value: MessageLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn reply_to(mut self, value: SendMessageReplyTo) -> Self { + self.reply_to = Some(value); + self + } + + pub fn to(mut self, value: SendMessageTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: SendMessageCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: SendMessageBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: MessageSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn text(mut self, value: MessageText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: MessageHtml) -> Self { + self.html = Some(value); + self + } + + pub fn attachments(mut self, value: SendMessageAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn headers(mut self, value: SendMessageHeaders) -> Self { + self.headers = Some(value); + self + } + + pub fn track_opens(mut self, value: TrackOpens) -> Self { + self.track_opens = Some(value); + self + } + + /// Consumes the builder and constructs a [`SendMessageRequest`]. + pub fn build(self) -> Result { + Ok(SendMessageRequest { + labels: self.labels, + reply_to: self.reply_to, + to: self.to, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + text: self.text, + html: self.html, + attachments: self.attachments, + headers: self.headers, + track_opens: self.track_opens, + }) + } +} diff --git a/agentmail-types/src/types/send_message_response.rs b/agentmail-types/src/types/send_message_response.rs new file mode 100644 index 0000000..d4bc013 --- /dev/null +++ b/agentmail-types/src/types/send_message_response.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct SendMessageResponse { + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub thread_id: ThreadId, +} + +impl SendMessageResponse { + pub fn builder() -> SendMessageResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SendMessageResponseBuilder { + message_id: Option, + thread_id: Option, +} + +impl SendMessageResponseBuilder { + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + /// Consumes the builder and constructs a [`SendMessageResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`message_id`](SendMessageResponseBuilder::message_id) + /// - [`thread_id`](SendMessageResponseBuilder::thread_id) + pub fn build(self) -> Result { + Ok(SendMessageResponse { + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + }) + } +} diff --git a/agentmail-types/src/types/send_message_to.rs b/agentmail-types/src/types/send_message_to.rs new file mode 100644 index 0000000..4af039c --- /dev/null +++ b/agentmail-types/src/types/send_message_to.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct SendMessageTo(pub Addresses); \ No newline at end of file diff --git a/agentmail-types/src/types/start.rs b/agentmail-types/src/types/start.rs new file mode 100644 index 0000000..e9b4968 --- /dev/null +++ b/agentmail-types/src/types/start.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Start( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/status.rs b/agentmail-types/src/types/status.rs new file mode 100644 index 0000000..9e35c2a --- /dev/null +++ b/agentmail-types/src/types/status.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Status(pub VerificationStatus); \ No newline at end of file diff --git a/agentmail-types/src/types/subdomains_enabled.rs b/agentmail-types/src/types/subdomains_enabled.rs new file mode 100644 index 0000000..400a6cf --- /dev/null +++ b/agentmail-types/src/types/subdomains_enabled.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct SubdomainsEnabled(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/subscribe.rs b/agentmail-types/src/types/subscribe.rs new file mode 100644 index 0000000..a45a1dc --- /dev/null +++ b/agentmail-types/src/types/subscribe.rs @@ -0,0 +1,63 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Subscribe { + pub r#type: SubscribeType, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_types: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_ids: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_ids: Option, +} + +impl Subscribe { + pub fn builder() -> SubscribeBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SubscribeBuilder { + r#type: Option, + event_types: Option, + inbox_ids: Option, + pod_ids: Option, +} + +impl SubscribeBuilder { + pub fn r#type(mut self, value: SubscribeType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_types(mut self, value: EventTypes) -> Self { + self.event_types = Some(value); + self + } + + pub fn inbox_ids(mut self, value: InboxIds) -> Self { + self.inbox_ids = Some(value); + self + } + + pub fn pod_ids(mut self, value: PodIds) -> Self { + self.pod_ids = Some(value); + self + } + + /// Consumes the builder and constructs a [`Subscribe`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](SubscribeBuilder::r#type) + pub fn build(self) -> Result { + Ok(Subscribe { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_types: self.event_types, + inbox_ids: self.inbox_ids, + pod_ids: self.pod_ids, + }) + } +} diff --git a/agentmail-types/src/types/subscribe_type.rs b/agentmail-types/src/types/subscribe_type.rs new file mode 100644 index 0000000..753205f --- /dev/null +++ b/agentmail-types/src/types/subscribe_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum SubscribeType { + #[serde(rename = "subscribe")] + Subscribe, +} +impl fmt::Display for SubscribeType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Subscribe => "subscribe", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/subscribed.rs b/agentmail-types/src/types/subscribed.rs new file mode 100644 index 0000000..55f82da --- /dev/null +++ b/agentmail-types/src/types/subscribed.rs @@ -0,0 +1,63 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct Subscribed { + pub r#type: SubscribedType, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_types: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_ids: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_ids: Option, +} + +impl Subscribed { + pub fn builder() -> SubscribedBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct SubscribedBuilder { + r#type: Option, + event_types: Option, + inbox_ids: Option, + pod_ids: Option, +} + +impl SubscribedBuilder { + pub fn r#type(mut self, value: SubscribedType) -> Self { + self.r#type = Some(value); + self + } + + pub fn event_types(mut self, value: EventTypes) -> Self { + self.event_types = Some(value); + self + } + + pub fn inbox_ids(mut self, value: InboxIds) -> Self { + self.inbox_ids = Some(value); + self + } + + pub fn pod_ids(mut self, value: PodIds) -> Self { + self.pod_ids = Some(value); + self + } + + /// Consumes the builder and constructs a [`Subscribed`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](SubscribedBuilder::r#type) + pub fn build(self) -> Result { + Ok(Subscribed { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + event_types: self.event_types, + inbox_ids: self.inbox_ids, + pod_ids: self.pod_ids, + }) + } +} diff --git a/agentmail-types/src/types/subscribed_type.rs b/agentmail-types/src/types/subscribed_type.rs new file mode 100644 index 0000000..0bad7f2 --- /dev/null +++ b/agentmail-types/src/types/subscribed_type.rs @@ -0,0 +1,17 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum SubscribedType { + #[serde(rename = "subscribed")] + Subscribed, +} +impl fmt::Display for SubscribedType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Subscribed => "subscribed", + }; + write!(f, "{}", s) + } +} diff --git a/agentmail-types/src/types/thread.rs b/agentmail-types/src/types/thread.rs new file mode 100644 index 0000000..b84a9f1 --- /dev/null +++ b/agentmail-types/src/types/thread.rs @@ -0,0 +1,193 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct Thread { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub labels: ThreadLabels, + #[serde(default)] + pub timestamp: ThreadTimestamp, + #[serde(skip_serializing_if = "Option::is_none")] + pub received_timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sent_timestamp: Option, + #[serde(default)] + pub senders: ThreadSenders, + #[serde(default)] + pub recipients: ThreadRecipients, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(default)] + pub last_message_id: ThreadLastMessageId, + #[serde(default)] + pub message_count: ThreadMessageCount, + #[serde(default)] + pub size: ThreadSize, + #[serde(default)] + pub updated_at: ThreadUpdatedAt, + #[serde(default)] + pub created_at: ThreadCreatedAt, + /// Messages in thread. Ordered by `timestamp` ascending. + #[serde(default)] + pub messages: Vec, +} + +impl Thread { + pub fn builder() -> ThreadBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ThreadBuilder { + inbox_id: Option, + thread_id: Option, + labels: Option, + timestamp: Option, + received_timestamp: Option, + sent_timestamp: Option, + senders: Option, + recipients: Option, + subject: Option, + preview: Option, + attachments: Option, + last_message_id: Option, + message_count: Option, + size: Option, + updated_at: Option, + created_at: Option, + messages: Option>, +} + +impl ThreadBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn labels(mut self, value: ThreadLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn timestamp(mut self, value: ThreadTimestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn received_timestamp(mut self, value: ThreadReceivedTimestamp) -> Self { + self.received_timestamp = Some(value); + self + } + + pub fn sent_timestamp(mut self, value: ThreadSentTimestamp) -> Self { + self.sent_timestamp = Some(value); + self + } + + pub fn senders(mut self, value: ThreadSenders) -> Self { + self.senders = Some(value); + self + } + + pub fn recipients(mut self, value: ThreadRecipients) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: ThreadSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn preview(mut self, value: ThreadPreview) -> Self { + self.preview = Some(value); + self + } + + pub fn attachments(mut self, value: ThreadAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn last_message_id(mut self, value: ThreadLastMessageId) -> Self { + self.last_message_id = Some(value); + self + } + + pub fn message_count(mut self, value: ThreadMessageCount) -> Self { + self.message_count = Some(value); + self + } + + pub fn size(mut self, value: ThreadSize) -> Self { + self.size = Some(value); + self + } + + pub fn updated_at(mut self, value: ThreadUpdatedAt) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: ThreadCreatedAt) -> Self { + self.created_at = Some(value); + self + } + + pub fn messages(mut self, value: Vec) -> Self { + self.messages = Some(value); + self + } + + /// Consumes the builder and constructs a [`Thread`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](ThreadBuilder::inbox_id) + /// - [`thread_id`](ThreadBuilder::thread_id) + /// - [`labels`](ThreadBuilder::labels) + /// - [`timestamp`](ThreadBuilder::timestamp) + /// - [`senders`](ThreadBuilder::senders) + /// - [`recipients`](ThreadBuilder::recipients) + /// - [`last_message_id`](ThreadBuilder::last_message_id) + /// - [`message_count`](ThreadBuilder::message_count) + /// - [`size`](ThreadBuilder::size) + /// - [`updated_at`](ThreadBuilder::updated_at) + /// - [`created_at`](ThreadBuilder::created_at) + /// - [`messages`](ThreadBuilder::messages) + pub fn build(self) -> Result { + Ok(Thread { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + received_timestamp: self.received_timestamp, + sent_timestamp: self.sent_timestamp, + senders: self.senders.ok_or_else(|| BuildError::missing_field("senders"))?, + recipients: self.recipients.ok_or_else(|| BuildError::missing_field("recipients"))?, + subject: self.subject, + preview: self.preview, + attachments: self.attachments, + last_message_id: self.last_message_id.ok_or_else(|| BuildError::missing_field("last_message_id"))?, + message_count: self.message_count.ok_or_else(|| BuildError::missing_field("message_count"))?, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + messages: self.messages.ok_or_else(|| BuildError::missing_field("messages"))?, + }) + } +} diff --git a/agentmail-types/src/types/thread_attachments.rs b/agentmail-types/src/types/thread_attachments.rs new file mode 100644 index 0000000..6810d4f --- /dev/null +++ b/agentmail-types/src/types/thread_attachments.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadAttachments(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_created_at.rs b/agentmail-types/src/types/thread_created_at.rs new file mode 100644 index 0000000..b956e48 --- /dev/null +++ b/agentmail-types/src/types/thread_created_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadCreatedAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_id.rs b/agentmail-types/src/types/thread_id.rs new file mode 100644 index 0000000..727d6cc --- /dev/null +++ b/agentmail-types/src/types/thread_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_item.rs b/agentmail-types/src/types/thread_item.rs new file mode 100644 index 0000000..4cc4b84 --- /dev/null +++ b/agentmail-types/src/types/thread_item.rs @@ -0,0 +1,182 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ThreadItem { + #[serde(default)] + pub inbox_id: InboxesInboxId, + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub labels: ThreadLabels, + #[serde(default)] + pub timestamp: ThreadTimestamp, + #[serde(skip_serializing_if = "Option::is_none")] + pub received_timestamp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sent_timestamp: Option, + #[serde(default)] + pub senders: ThreadSenders, + #[serde(default)] + pub recipients: ThreadRecipients, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub attachments: Option, + #[serde(default)] + pub last_message_id: ThreadLastMessageId, + #[serde(default)] + pub message_count: ThreadMessageCount, + #[serde(default)] + pub size: ThreadSize, + #[serde(default)] + pub updated_at: ThreadUpdatedAt, + #[serde(default)] + pub created_at: ThreadCreatedAt, +} + +impl ThreadItem { + pub fn builder() -> ThreadItemBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ThreadItemBuilder { + inbox_id: Option, + thread_id: Option, + labels: Option, + timestamp: Option, + received_timestamp: Option, + sent_timestamp: Option, + senders: Option, + recipients: Option, + subject: Option, + preview: Option, + attachments: Option, + last_message_id: Option, + message_count: Option, + size: Option, + updated_at: Option, + created_at: Option, +} + +impl ThreadItemBuilder { + pub fn inbox_id(mut self, value: InboxesInboxId) -> Self { + self.inbox_id = Some(value); + self + } + + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn labels(mut self, value: ThreadLabels) -> Self { + self.labels = Some(value); + self + } + + pub fn timestamp(mut self, value: ThreadTimestamp) -> Self { + self.timestamp = Some(value); + self + } + + pub fn received_timestamp(mut self, value: ThreadReceivedTimestamp) -> Self { + self.received_timestamp = Some(value); + self + } + + pub fn sent_timestamp(mut self, value: ThreadSentTimestamp) -> Self { + self.sent_timestamp = Some(value); + self + } + + pub fn senders(mut self, value: ThreadSenders) -> Self { + self.senders = Some(value); + self + } + + pub fn recipients(mut self, value: ThreadRecipients) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: ThreadSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn preview(mut self, value: ThreadPreview) -> Self { + self.preview = Some(value); + self + } + + pub fn attachments(mut self, value: ThreadAttachments) -> Self { + self.attachments = Some(value); + self + } + + pub fn last_message_id(mut self, value: ThreadLastMessageId) -> Self { + self.last_message_id = Some(value); + self + } + + pub fn message_count(mut self, value: ThreadMessageCount) -> Self { + self.message_count = Some(value); + self + } + + pub fn size(mut self, value: ThreadSize) -> Self { + self.size = Some(value); + self + } + + pub fn updated_at(mut self, value: ThreadUpdatedAt) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: ThreadCreatedAt) -> Self { + self.created_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`ThreadItem`]. + /// This method will fail if any of the following fields are not set: + /// - [`inbox_id`](ThreadItemBuilder::inbox_id) + /// - [`thread_id`](ThreadItemBuilder::thread_id) + /// - [`labels`](ThreadItemBuilder::labels) + /// - [`timestamp`](ThreadItemBuilder::timestamp) + /// - [`senders`](ThreadItemBuilder::senders) + /// - [`recipients`](ThreadItemBuilder::recipients) + /// - [`last_message_id`](ThreadItemBuilder::last_message_id) + /// - [`message_count`](ThreadItemBuilder::message_count) + /// - [`size`](ThreadItemBuilder::size) + /// - [`updated_at`](ThreadItemBuilder::updated_at) + /// - [`created_at`](ThreadItemBuilder::created_at) + pub fn build(self) -> Result { + Ok(ThreadItem { + inbox_id: self.inbox_id.ok_or_else(|| BuildError::missing_field("inbox_id"))?, + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + received_timestamp: self.received_timestamp, + sent_timestamp: self.sent_timestamp, + senders: self.senders.ok_or_else(|| BuildError::missing_field("senders"))?, + recipients: self.recipients.ok_or_else(|| BuildError::missing_field("recipients"))?, + subject: self.subject, + preview: self.preview, + attachments: self.attachments, + last_message_id: self.last_message_id.ok_or_else(|| BuildError::missing_field("last_message_id"))?, + message_count: self.message_count.ok_or_else(|| BuildError::missing_field("message_count"))?, + size: self.size.ok_or_else(|| BuildError::missing_field("size"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + }) + } +} diff --git a/agentmail-types/src/types/thread_labels.rs b/agentmail-types/src/types/thread_labels.rs new file mode 100644 index 0000000..cf8914c --- /dev/null +++ b/agentmail-types/src/types/thread_labels.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadLabels(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_last_message_id.rs b/agentmail-types/src/types/thread_last_message_id.rs new file mode 100644 index 0000000..2d923c7 --- /dev/null +++ b/agentmail-types/src/types/thread_last_message_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadLastMessageId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_message_count.rs b/agentmail-types/src/types/thread_message_count.rs new file mode 100644 index 0000000..c4d39fa --- /dev/null +++ b/agentmail-types/src/types/thread_message_count.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadMessageCount(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_preview.rs b/agentmail-types/src/types/thread_preview.rs new file mode 100644 index 0000000..96b53fe --- /dev/null +++ b/agentmail-types/src/types/thread_preview.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadPreview(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_received_timestamp.rs b/agentmail-types/src/types/thread_received_timestamp.rs new file mode 100644 index 0000000..ff54217 --- /dev/null +++ b/agentmail-types/src/types/thread_received_timestamp.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadReceivedTimestamp( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_recipients.rs b/agentmail-types/src/types/thread_recipients.rs new file mode 100644 index 0000000..c7be831 --- /dev/null +++ b/agentmail-types/src/types/thread_recipients.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadRecipients(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_senders.rs b/agentmail-types/src/types/thread_senders.rs new file mode 100644 index 0000000..851211e --- /dev/null +++ b/agentmail-types/src/types/thread_senders.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadSenders(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_sent_timestamp.rs b/agentmail-types/src/types/thread_sent_timestamp.rs new file mode 100644 index 0000000..ae5dae8 --- /dev/null +++ b/agentmail-types/src/types/thread_sent_timestamp.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadSentTimestamp( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_size.rs b/agentmail-types/src/types/thread_size.rs new file mode 100644 index 0000000..a036e12 --- /dev/null +++ b/agentmail-types/src/types/thread_size.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadSize(pub i64); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_subject.rs b/agentmail-types/src/types/thread_subject.rs new file mode 100644 index 0000000..9b6d0d8 --- /dev/null +++ b/agentmail-types/src/types/thread_subject.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadSubject(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_timestamp.rs b/agentmail-types/src/types/thread_timestamp.rs new file mode 100644 index 0000000..416bff0 --- /dev/null +++ b/agentmail-types/src/types/thread_timestamp.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadTimestamp( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/thread_updated_at.rs b/agentmail-types/src/types/thread_updated_at.rs new file mode 100644 index 0000000..a0e7683 --- /dev/null +++ b/agentmail-types/src/types/thread_updated_at.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct ThreadUpdatedAt( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/threads_list_query_request.rs b/agentmail-types/src/types/threads_list_query_request.rs new file mode 100644 index 0000000..6f2d5d9 --- /dev/null +++ b/agentmail-types/src/types/threads_list_query_request.rs @@ -0,0 +1,150 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ThreadsListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(default)] + pub labels: Vec>, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_spam: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_blocked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_unauthenticated: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_trash: Option, + /// Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub senders: Option>, + /// Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub recipients: Option>, + /// Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option>, +} + +impl ThreadsListQueryRequest { + pub fn builder() -> ThreadsListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ThreadsListQueryRequestBuilder { + limit: Option, + page_token: Option, + labels: Option>>, + before: Option, + after: Option, + ascending: Option, + include_spam: Option, + include_blocked: Option, + include_unauthenticated: Option, + include_trash: Option, + senders: Option>, + recipients: Option>, + subject: Option>, +} + +impl ThreadsListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn labels(mut self, value: Vec>) -> Self { + self.labels = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + pub fn include_spam(mut self, value: IncludeSpam) -> Self { + self.include_spam = Some(value); + self + } + + pub fn include_blocked(mut self, value: IncludeBlocked) -> Self { + self.include_blocked = Some(value); + self + } + + pub fn include_unauthenticated(mut self, value: IncludeUnauthenticated) -> Self { + self.include_unauthenticated = Some(value); + self + } + + pub fn include_trash(mut self, value: IncludeTrash) -> Self { + self.include_trash = Some(value); + self + } + + pub fn senders(mut self, value: Vec) -> Self { + self.senders = Some(value); + self + } + + pub fn recipients(mut self, value: Vec) -> Self { + self.recipients = Some(value); + self + } + + pub fn subject(mut self, value: Vec) -> Self { + self.subject = Some(value); + self + } + + /// Consumes the builder and constructs a [`ThreadsListQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`labels`](ThreadsListQueryRequestBuilder::labels) + pub fn build(self) -> Result { + Ok(ThreadsListQueryRequest { + limit: self.limit, + page_token: self.page_token, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + before: self.before, + after: self.after, + ascending: self.ascending, + include_spam: self.include_spam, + include_blocked: self.include_blocked, + include_unauthenticated: self.include_unauthenticated, + include_trash: self.include_trash, + senders: self.senders, + recipients: self.recipients, + subject: self.subject, + }) + } +} + diff --git a/agentmail-types/src/types/threads_search_query_request.rs b/agentmail-types/src/types/threads_search_query_request.rs new file mode 100644 index 0000000..875fdef --- /dev/null +++ b/agentmail-types/src/types/threads_search_query_request.rs @@ -0,0 +1,75 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for search +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct ThreadsSearchQueryRequest { + #[serde(default)] + pub q: Query, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub after: Option, +} + +impl ThreadsSearchQueryRequest { + pub fn builder() -> ThreadsSearchQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ThreadsSearchQueryRequestBuilder { + q: Option, + limit: Option, + page_token: Option, + before: Option, + after: Option, +} + +impl ThreadsSearchQueryRequestBuilder { + pub fn q(mut self, value: Query) -> Self { + self.q = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn before(mut self, value: Before) -> Self { + self.before = Some(value); + self + } + + pub fn after(mut self, value: After) -> Self { + self.after = Some(value); + self + } + + /// Consumes the builder and constructs a [`ThreadsSearchQueryRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`q`](ThreadsSearchQueryRequestBuilder::q) + pub fn build(self) -> Result { + Ok(ThreadsSearchQueryRequest { + q: self.q.ok_or_else(|| BuildError::missing_field("q"))?, + limit: self.limit, + page_token: self.page_token, + before: self.before, + after: self.after, + }) + } +} + diff --git a/agentmail-types/src/types/timestamp.rs b/agentmail-types/src/types/timestamp.rs new file mode 100644 index 0000000..737ad5c --- /dev/null +++ b/agentmail-types/src/types/timestamp.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct Timestamp( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/track_opens.rs b/agentmail-types/src/types/track_opens.rs new file mode 100644 index 0000000..ce9e9c2 --- /dev/null +++ b/agentmail-types/src/types/track_opens.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct TrackOpens(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/tracking_enabled.rs b/agentmail-types/src/types/tracking_enabled.rs new file mode 100644 index 0000000..9c23b4f --- /dev/null +++ b/agentmail-types/src/types/tracking_enabled.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct TrackingEnabled(pub bool); \ No newline at end of file diff --git a/agentmail-types/src/types/update_domain_request.rs b/agentmail-types/src/types/update_domain_request.rs new file mode 100644 index 0000000..a591642 --- /dev/null +++ b/agentmail-types/src/types/update_domain_request.rs @@ -0,0 +1,58 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Provide at least one of `feedback_enabled`, `subdomains_enabled`, or +/// `tracking_enabled`. Omitted +/// fields are left unchanged; an empty body is rejected. Enabling +/// `subdomains_enabled` on a verified domain returns it to `PENDING` until the +/// newly-required wildcard MX record (`*.`) is published and verified. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdateDomainRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub feedback_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subdomains_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tracking_enabled: Option, +} + +impl UpdateDomainRequest { + pub fn builder() -> UpdateDomainRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdateDomainRequestBuilder { + feedback_enabled: Option, + subdomains_enabled: Option, + tracking_enabled: Option, +} + +impl UpdateDomainRequestBuilder { + pub fn feedback_enabled(mut self, value: FeedbackEnabled) -> Self { + self.feedback_enabled = Some(value); + self + } + + pub fn subdomains_enabled(mut self, value: SubdomainsEnabled) -> Self { + self.subdomains_enabled = Some(value); + self + } + + pub fn tracking_enabled(mut self, value: TrackingEnabled) -> Self { + self.tracking_enabled = Some(value); + self + } + + /// Consumes the builder and constructs a [`UpdateDomainRequest`]. + pub fn build(self) -> Result { + Ok(UpdateDomainRequest { + feedback_enabled: self.feedback_enabled, + subdomains_enabled: self.subdomains_enabled, + tracking_enabled: self.tracking_enabled, + }) + } +} diff --git a/agentmail-types/src/types/update_draft_request.rs b/agentmail-types/src/types/update_draft_request.rs new file mode 100644 index 0000000..bbd6c02 --- /dev/null +++ b/agentmail-types/src/types/update_draft_request.rs @@ -0,0 +1,139 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdateDraftRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcc: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subject: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub html: Option, + /// Attachments to add to the draft. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_attachments: Option>, + /// IDs of attachments to remove from the draft. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_attachments: Option>, + /// Label or labels to add to the draft. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_labels: Option, + /// Label or labels to remove from the draft. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_labels: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub send_at: Option, +} + +impl UpdateDraftRequest { + pub fn builder() -> UpdateDraftRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdateDraftRequestBuilder { + reply_to: Option, + to: Option, + cc: Option, + bcc: Option, + subject: Option, + text: Option, + html: Option, + add_attachments: Option>, + remove_attachments: Option>, + add_labels: Option, + remove_labels: Option, + send_at: Option, +} + +impl UpdateDraftRequestBuilder { + pub fn reply_to(mut self, value: DraftReplyTo) -> Self { + self.reply_to = Some(value); + self + } + + pub fn to(mut self, value: DraftTo) -> Self { + self.to = Some(value); + self + } + + pub fn cc(mut self, value: DraftCc) -> Self { + self.cc = Some(value); + self + } + + pub fn bcc(mut self, value: DraftBcc) -> Self { + self.bcc = Some(value); + self + } + + pub fn subject(mut self, value: DraftSubject) -> Self { + self.subject = Some(value); + self + } + + pub fn text(mut self, value: DraftText) -> Self { + self.text = Some(value); + self + } + + pub fn html(mut self, value: DraftHtml) -> Self { + self.html = Some(value); + self + } + + pub fn add_attachments(mut self, value: Vec) -> Self { + self.add_attachments = Some(value); + self + } + + pub fn remove_attachments(mut self, value: Vec) -> Self { + self.remove_attachments = Some(value); + self + } + + pub fn add_labels(mut self, value: DraftLabels) -> Self { + self.add_labels = Some(value); + self + } + + pub fn remove_labels(mut self, value: DraftLabels) -> Self { + self.remove_labels = Some(value); + self + } + + pub fn send_at(mut self, value: DraftSendAt) -> Self { + self.send_at = Some(value); + self + } + + /// Consumes the builder and constructs a [`UpdateDraftRequest`]. + pub fn build(self) -> Result { + Ok(UpdateDraftRequest { + reply_to: self.reply_to, + to: self.to, + cc: self.cc, + bcc: self.bcc, + subject: self.subject, + text: self.text, + html: self.html, + add_attachments: self.add_attachments, + remove_attachments: self.remove_attachments, + add_labels: self.add_labels, + remove_labels: self.remove_labels, + send_at: self.send_at, + }) + } +} + diff --git a/agentmail-types/src/types/update_message_labels.rs b/agentmail-types/src/types/update_message_labels.rs new file mode 100644 index 0000000..1025fbc --- /dev/null +++ b/agentmail-types/src/types/update_message_labels.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum UpdateMessageLabels { + String(String), + + StringList(Vec), +} + +impl UpdateMessageLabels { + pub fn is_string(&self) -> bool { + matches!(self, Self::String(_)) + } + + pub fn is_string_list(&self) -> bool { + matches!(self, Self::StringList(_)) + } + + + pub fn as_string(&self) -> Option<&str> { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn into_string(self) -> Option { + match self { + Self::String(value) => Some(value), + _ => None, + } + } + + pub fn as_string_list(&self) -> Option<&Vec> { + match self { + Self::StringList(value) => Some(value), + _ => None, + } + } + + pub fn into_string_list(self) -> Option> { + match self { + Self::StringList(value) => Some(value), + _ => None, + } + } +} diff --git a/agentmail-types/src/types/update_message_request.rs b/agentmail-types/src/types/update_message_request.rs new file mode 100644 index 0000000..ef79ec3 --- /dev/null +++ b/agentmail-types/src/types/update_message_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdateMessageRequest { + /// Label or labels to add to message. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_labels: Option, + /// Label or labels to remove from message. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_labels: Option, +} + +impl UpdateMessageRequest { + pub fn builder() -> UpdateMessageRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdateMessageRequestBuilder { + add_labels: Option, + remove_labels: Option, +} + +impl UpdateMessageRequestBuilder { + pub fn add_labels(mut self, value: UpdateMessageLabels) -> Self { + self.add_labels = Some(value); + self + } + + pub fn remove_labels(mut self, value: UpdateMessageLabels) -> Self { + self.remove_labels = Some(value); + self + } + + /// Consumes the builder and constructs a [`UpdateMessageRequest`]. + pub fn build(self) -> Result { + Ok(UpdateMessageRequest { + add_labels: self.add_labels, + remove_labels: self.remove_labels, + }) + } +} diff --git a/agentmail-types/src/types/update_message_response.rs b/agentmail-types/src/types/update_message_response.rs new file mode 100644 index 0000000..932195c --- /dev/null +++ b/agentmail-types/src/types/update_message_response.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdateMessageResponse { + #[serde(default)] + pub message_id: MessageId, + #[serde(default)] + pub labels: MessageLabels, +} + +impl UpdateMessageResponse { + pub fn builder() -> UpdateMessageResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdateMessageResponseBuilder { + message_id: Option, + labels: Option, +} + +impl UpdateMessageResponseBuilder { + pub fn message_id(mut self, value: MessageId) -> Self { + self.message_id = Some(value); + self + } + + pub fn labels(mut self, value: MessageLabels) -> Self { + self.labels = Some(value); + self + } + + /// Consumes the builder and constructs a [`UpdateMessageResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`message_id`](UpdateMessageResponseBuilder::message_id) + /// - [`labels`](UpdateMessageResponseBuilder::labels) + pub fn build(self) -> Result { + Ok(UpdateMessageResponse { + message_id: self.message_id.ok_or_else(|| BuildError::missing_field("message_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + }) + } +} diff --git a/agentmail-types/src/types/update_public_key_name_request.rs b/agentmail-types/src/types/update_public_key_name_request.rs new file mode 100644 index 0000000..0bd80b2 --- /dev/null +++ b/agentmail-types/src/types/update_public_key_name_request.rs @@ -0,0 +1,38 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdatePublicKeyNameRequest { + #[serde(default)] + pub name: String, +} + +impl UpdatePublicKeyNameRequest { + pub fn builder() -> UpdatePublicKeyNameRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdatePublicKeyNameRequestBuilder { + name: Option, +} + +impl UpdatePublicKeyNameRequestBuilder { + pub fn name(mut self, value: impl Into) -> Self { + self.name = Some(value.into()); + self + } + + /// Consumes the builder and constructs a [`UpdatePublicKeyNameRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`name`](UpdatePublicKeyNameRequestBuilder::name) + pub fn build(self) -> Result { + Ok(UpdatePublicKeyNameRequest { + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + }) + } +} + diff --git a/agentmail-types/src/types/update_thread_request.rs b/agentmail-types/src/types/update_thread_request.rs new file mode 100644 index 0000000..3d40633 --- /dev/null +++ b/agentmail-types/src/types/update_thread_request.rs @@ -0,0 +1,46 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdateThreadRequest { + /// Labels to add to thread. Cannot be system labels. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_labels: Option>, + /// Labels to remove from thread. Cannot be system labels. Takes priority over `add_labels` (in the event of duplicate labels passed in). + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_labels: Option>, +} + +impl UpdateThreadRequest { + pub fn builder() -> UpdateThreadRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdateThreadRequestBuilder { + add_labels: Option>, + remove_labels: Option>, +} + +impl UpdateThreadRequestBuilder { + pub fn add_labels(mut self, value: Vec) -> Self { + self.add_labels = Some(value); + self + } + + pub fn remove_labels(mut self, value: Vec) -> Self { + self.remove_labels = Some(value); + self + } + + /// Consumes the builder and constructs a [`UpdateThreadRequest`]. + pub fn build(self) -> Result { + Ok(UpdateThreadRequest { + add_labels: self.add_labels, + remove_labels: self.remove_labels, + }) + } +} diff --git a/agentmail-types/src/types/update_thread_response.rs b/agentmail-types/src/types/update_thread_response.rs new file mode 100644 index 0000000..6a182b4 --- /dev/null +++ b/agentmail-types/src/types/update_thread_response.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UpdateThreadResponse { + #[serde(default)] + pub thread_id: ThreadId, + #[serde(default)] + pub labels: ThreadLabels, +} + +impl UpdateThreadResponse { + pub fn builder() -> UpdateThreadResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UpdateThreadResponseBuilder { + thread_id: Option, + labels: Option, +} + +impl UpdateThreadResponseBuilder { + pub fn thread_id(mut self, value: ThreadId) -> Self { + self.thread_id = Some(value); + self + } + + pub fn labels(mut self, value: ThreadLabels) -> Self { + self.labels = Some(value); + self + } + + /// Consumes the builder and constructs a [`UpdateThreadResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`thread_id`](UpdateThreadResponseBuilder::thread_id) + /// - [`labels`](UpdateThreadResponseBuilder::labels) + pub fn build(self) -> Result { + Ok(UpdateThreadResponse { + thread_id: self.thread_id.ok_or_else(|| BuildError::missing_field("thread_id"))?, + labels: self.labels.ok_or_else(|| BuildError::missing_field("labels"))?, + }) + } +} diff --git a/agentmail-types/src/types/usage_point.rs b/agentmail-types/src/types/usage_point.rs new file mode 100644 index 0000000..4a328e8 --- /dev/null +++ b/agentmail-types/src/types/usage_point.rs @@ -0,0 +1,50 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct UsagePoint { + /// Timestamp of the point. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub timestamp: DateTime, + /// Cumulative value of the usage metric at the timestamp. + #[serde(default)] + pub value: i64, +} + +impl UsagePoint { + pub fn builder() -> UsagePointBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct UsagePointBuilder { + timestamp: Option>, + value: Option, +} + +impl UsagePointBuilder { + pub fn timestamp(mut self, value: DateTime) -> Self { + self.timestamp = Some(value); + self + } + + pub fn value(mut self, value: i64) -> Self { + self.value = Some(value); + self + } + + /// Consumes the builder and constructs a [`UsagePoint`]. + /// This method will fail if any of the following fields are not set: + /// - [`timestamp`](UsagePointBuilder::timestamp) + /// - [`value`](UsagePointBuilder::value) + pub fn build(self) -> Result { + Ok(UsagePoint { + timestamp: self.timestamp.ok_or_else(|| BuildError::missing_field("timestamp"))?, + value: self.value.ok_or_else(|| BuildError::missing_field("value"))?, + }) + } +} diff --git a/agentmail-types/src/types/usage_type.rs b/agentmail-types/src/types/usage_type.rs new file mode 100644 index 0000000..40d8de2 --- /dev/null +++ b/agentmail-types/src/types/usage_type.rs @@ -0,0 +1,63 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Type of usage metric. Inbox-scoped queries carry `storage_bytes`, +/// `message_count`, and `thread_count`; pod-scoped queries add `inbox_count` +/// and `domain_count`; organization-scoped queries add `pod_count`. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UsageType { + StorageBytes, + MessageCount, + ThreadCount, + InboxCount, + PodCount, + DomainCount, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for UsageType { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::StorageBytes => serializer.serialize_str("storage_bytes"), + Self::MessageCount => serializer.serialize_str("message_count"), + Self::ThreadCount => serializer.serialize_str("thread_count"), + Self::InboxCount => serializer.serialize_str("inbox_count"), + Self::PodCount => serializer.serialize_str("pod_count"), + Self::DomainCount => serializer.serialize_str("domain_count"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for UsageType { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "storage_bytes" => Ok(Self::StorageBytes), + "message_count" => Ok(Self::MessageCount), + "thread_count" => Ok(Self::ThreadCount), + "inbox_count" => Ok(Self::InboxCount), + "pod_count" => Ok(Self::PodCount), + "domain_count" => Ok(Self::DomainCount), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for UsageType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StorageBytes => write!(f, "storage_bytes"), + Self::MessageCount => write!(f, "message_count"), + Self::ThreadCount => write!(f, "thread_count"), + Self::InboxCount => write!(f, "inbox_count"), + Self::PodCount => write!(f, "pod_count"), + Self::DomainCount => write!(f, "domain_count"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/usage_types.rs b/agentmail-types/src/types/usage_types.rs new file mode 100644 index 0000000..af0a57e --- /dev/null +++ b/agentmail-types/src/types/usage_types.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct UsageTypes(pub Vec); \ No newline at end of file diff --git a/agentmail-types/src/types/validation_error_response.rs b/agentmail-types/src/types/validation_error_response.rs new file mode 100644 index 0000000..34f30ee --- /dev/null +++ b/agentmail-types/src/types/validation_error_response.rs @@ -0,0 +1,82 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct ValidationErrorResponse { + #[serde(default)] + pub name: ErrorName, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + pub errors: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub fix: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub docs: Option, +} + +impl ValidationErrorResponse { + pub fn builder() -> ValidationErrorResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct ValidationErrorResponseBuilder { + name: Option, + code: Option, + message: Option, + errors: Option, + fix: Option, + docs: Option, +} + +impl ValidationErrorResponseBuilder { + pub fn name(mut self, value: ErrorName) -> Self { + self.name = Some(value); + self + } + + pub fn code(mut self, value: ErrorCode) -> Self { + self.code = Some(value); + self + } + + pub fn message(mut self, value: ErrorMessage) -> Self { + self.message = Some(value); + self + } + + pub fn errors(mut self, value: serde_json::Value) -> Self { + self.errors = Some(value); + self + } + + pub fn fix(mut self, value: ErrorFix) -> Self { + self.fix = Some(value); + self + } + + pub fn docs(mut self, value: ErrorDocs) -> Self { + self.docs = Some(value); + self + } + + /// Consumes the builder and constructs a [`ValidationErrorResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`name`](ValidationErrorResponseBuilder::name) + /// - [`errors`](ValidationErrorResponseBuilder::errors) + pub fn build(self) -> Result { + Ok(ValidationErrorResponse { + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + code: self.code, + message: self.message, + errors: self.errors.ok_or_else(|| BuildError::missing_field("errors"))?, + fix: self.fix, + docs: self.docs, + }) + } +} diff --git a/agentmail-types/src/types/verification_record.rs b/agentmail-types/src/types/verification_record.rs new file mode 100644 index 0000000..cfe0197 --- /dev/null +++ b/agentmail-types/src/types/verification_record.rs @@ -0,0 +1,79 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct VerificationRecord { + /// The type of the DNS record. + pub r#type: RecordType, + /// The name or host of the record. + #[serde(default)] + pub name: String, + /// The value of the record. + #[serde(default)] + pub value: String, + /// The verification status of this specific record. + pub status: RecordStatus, + /// The priority of the MX record. + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, +} + +impl VerificationRecord { + pub fn builder() -> VerificationRecordBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct VerificationRecordBuilder { + r#type: Option, + name: Option, + value: Option, + status: Option, + priority: Option, +} + +impl VerificationRecordBuilder { + pub fn r#type(mut self, value: RecordType) -> Self { + self.r#type = Some(value); + self + } + + pub fn name(mut self, value: impl Into) -> Self { + self.name = Some(value.into()); + self + } + + pub fn value(mut self, value: impl Into) -> Self { + self.value = Some(value.into()); + self + } + + pub fn status(mut self, value: RecordStatus) -> Self { + self.status = Some(value); + self + } + + pub fn priority(mut self, value: i64) -> Self { + self.priority = Some(value); + self + } + + /// Consumes the builder and constructs a [`VerificationRecord`]. + /// This method will fail if any of the following fields are not set: + /// - [`r#type`](VerificationRecordBuilder::r#type) + /// - [`name`](VerificationRecordBuilder::name) + /// - [`value`](VerificationRecordBuilder::value) + /// - [`status`](VerificationRecordBuilder::status) + pub fn build(self) -> Result { + Ok(VerificationRecord { + r#type: self.r#type.ok_or_else(|| BuildError::missing_field("r#type"))?, + name: self.name.ok_or_else(|| BuildError::missing_field("name"))?, + value: self.value.ok_or_else(|| BuildError::missing_field("value"))?, + status: self.status.ok_or_else(|| BuildError::missing_field("status"))?, + priority: self.priority, + }) + } +} diff --git a/agentmail-types/src/types/verification_status.rs b/agentmail-types/src/types/verification_status.rs new file mode 100644 index 0000000..e438ac3 --- /dev/null +++ b/agentmail-types/src/types/verification_status.rs @@ -0,0 +1,60 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VerificationStatus { + NotStarted, + Pending, + Invalid, + Failed, + Verifying, + Verified, + /// This variant is used for forward compatibility. + /// If the server sends a value not recognized by the current SDK version, + /// it will be captured here with the raw string value. + __Unknown(String), +} +impl Serialize for VerificationStatus { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::NotStarted => serializer.serialize_str("NOT_STARTED"), + Self::Pending => serializer.serialize_str("PENDING"), + Self::Invalid => serializer.serialize_str("INVALID"), + Self::Failed => serializer.serialize_str("FAILED"), + Self::Verifying => serializer.serialize_str("VERIFYING"), + Self::Verified => serializer.serialize_str("VERIFIED"), + Self::__Unknown(val) => serializer.serialize_str(val), + } + } +} + +impl<'de> Deserialize<'de> for VerificationStatus { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + match value.as_str() { + "NOT_STARTED" => Ok(Self::NotStarted), + "PENDING" => Ok(Self::Pending), + "INVALID" => Ok(Self::Invalid), + "FAILED" => Ok(Self::Failed), + "VERIFYING" => Ok(Self::Verifying), + "VERIFIED" => Ok(Self::Verified), + _ => Ok(Self::__Unknown(value)), + } + } +} + +impl fmt::Display for VerificationStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotStarted => write!(f, "NOT_STARTED"), + Self::Pending => write!(f, "PENDING"), + Self::Invalid => write!(f, "INVALID"), + Self::Failed => write!(f, "FAILED"), + Self::Verifying => write!(f, "VERIFYING"), + Self::Verified => write!(f, "VERIFIED"), + Self::__Unknown(val) => write!(f, "{}", val), + } + } +} diff --git a/agentmail-types/src/types/webhooks_client_id.rs b/agentmail-types/src/types/webhooks_client_id.rs new file mode 100644 index 0000000..4a10be9 --- /dev/null +++ b/agentmail-types/src/types/webhooks_client_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksClientId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_create_inbox_webhook_request.rs b/agentmail-types/src/types/webhooks_create_inbox_webhook_request.rs new file mode 100644 index 0000000..1c4309f --- /dev/null +++ b/agentmail-types/src/types/webhooks_create_inbox_webhook_request.rs @@ -0,0 +1,67 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Create a webhook scoped to an inbox. The inbox comes from the path, so `inbox_ids` and `pod_ids` +/// are not accepted. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct WebhooksCreateInboxWebhookRequest { + #[serde(default)] + pub url: WebhooksUrl, + #[serde(default)] + pub event_types: WebhooksCreateWebhookEventTypes, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, +} + +impl WebhooksCreateInboxWebhookRequest { + pub fn builder() -> WebhooksCreateInboxWebhookRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksCreateInboxWebhookRequestBuilder { + url: Option, + event_types: Option, + client_id: Option, + headers: Option, +} + +impl WebhooksCreateInboxWebhookRequestBuilder { + pub fn url(mut self, value: WebhooksUrl) -> Self { + self.url = Some(value); + self + } + + pub fn event_types(mut self, value: WebhooksCreateWebhookEventTypes) -> Self { + self.event_types = Some(value); + self + } + + pub fn client_id(mut self, value: WebhooksClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn headers(mut self, value: WebhooksWebhookHeaders) -> Self { + self.headers = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksCreateInboxWebhookRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`url`](WebhooksCreateInboxWebhookRequestBuilder::url) + /// - [`event_types`](WebhooksCreateInboxWebhookRequestBuilder::event_types) + pub fn build(self) -> Result { + Ok(WebhooksCreateInboxWebhookRequest { + url: self.url.ok_or_else(|| BuildError::missing_field("url"))?, + event_types: self.event_types.ok_or_else(|| BuildError::missing_field("event_types"))?, + client_id: self.client_id, + headers: self.headers, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_create_pod_webhook_request.rs b/agentmail-types/src/types/webhooks_create_pod_webhook_request.rs new file mode 100644 index 0000000..6a6a121 --- /dev/null +++ b/agentmail-types/src/types/webhooks_create_pod_webhook_request.rs @@ -0,0 +1,49 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Create a webhook scoped to a pod. The pod comes from the path, so `pod_ids` is not accepted. +/// Optionally pass `inbox_ids` to narrow the webhook to specific inboxes within the pod; omit to +/// receive events for the whole pod. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct WebhooksCreatePodWebhookRequest { + #[serde(flatten)] + pub webhooks_create_inbox_webhook_request_fields: WebhooksCreateInboxWebhookRequest, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_ids: Option, +} + +impl WebhooksCreatePodWebhookRequest { + pub fn builder() -> WebhooksCreatePodWebhookRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksCreatePodWebhookRequestBuilder { + webhooks_create_inbox_webhook_request_fields: Option, + inbox_ids: Option, +} + +impl WebhooksCreatePodWebhookRequestBuilder { + pub fn webhooks_create_inbox_webhook_request_fields(mut self, value: WebhooksCreateInboxWebhookRequest) -> Self { + self.webhooks_create_inbox_webhook_request_fields = Some(value); + self + } + + pub fn inbox_ids(mut self, value: InboxIds) -> Self { + self.inbox_ids = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksCreatePodWebhookRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`webhooks_create_inbox_webhook_request_fields`](WebhooksCreatePodWebhookRequestBuilder::webhooks_create_inbox_webhook_request_fields) + pub fn build(self) -> Result { + Ok(WebhooksCreatePodWebhookRequest { + webhooks_create_inbox_webhook_request_fields: self.webhooks_create_inbox_webhook_request_fields.ok_or_else(|| BuildError::missing_field("webhooks_create_inbox_webhook_request_fields"))?, + inbox_ids: self.inbox_ids, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_create_webhook_event_types.rs b/agentmail-types/src/types/webhooks_create_webhook_event_types.rs new file mode 100644 index 0000000..54ce91d --- /dev/null +++ b/agentmail-types/src/types/webhooks_create_webhook_event_types.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksCreateWebhookEventTypes(pub EventTypes); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_create_webhook_request.rs b/agentmail-types/src/types/webhooks_create_webhook_request.rs new file mode 100644 index 0000000..c2492a0 --- /dev/null +++ b/agentmail-types/src/types/webhooks_create_webhook_request.rs @@ -0,0 +1,84 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct WebhooksCreateWebhookRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_ids: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_ids: Option, + #[serde(default)] + pub url: WebhooksUrl, + #[serde(default)] + pub event_types: WebhooksCreateWebhookEventTypes, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, +} + +impl WebhooksCreateWebhookRequest { + pub fn builder() -> WebhooksCreateWebhookRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksCreateWebhookRequestBuilder { + pod_ids: Option, + inbox_ids: Option, + url: Option, + event_types: Option, + client_id: Option, + headers: Option, +} + +impl WebhooksCreateWebhookRequestBuilder { + pub fn pod_ids(mut self, value: PodIds) -> Self { + self.pod_ids = Some(value); + self + } + + pub fn inbox_ids(mut self, value: InboxIds) -> Self { + self.inbox_ids = Some(value); + self + } + + pub fn url(mut self, value: WebhooksUrl) -> Self { + self.url = Some(value); + self + } + + pub fn event_types(mut self, value: WebhooksCreateWebhookEventTypes) -> Self { + self.event_types = Some(value); + self + } + + pub fn client_id(mut self, value: WebhooksClientId) -> Self { + self.client_id = Some(value); + self + } + + pub fn headers(mut self, value: WebhooksWebhookHeaders) -> Self { + self.headers = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksCreateWebhookRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`url`](WebhooksCreateWebhookRequestBuilder::url) + /// - [`event_types`](WebhooksCreateWebhookRequestBuilder::event_types) + pub fn build(self) -> Result { + Ok(WebhooksCreateWebhookRequest { + pod_ids: self.pod_ids, + inbox_ids: self.inbox_ids, + url: self.url.ok_or_else(|| BuildError::missing_field("url"))?, + event_types: self.event_types.ok_or_else(|| BuildError::missing_field("event_types"))?, + client_id: self.client_id, + headers: self.headers, + }) + } +} + diff --git a/agentmail-types/src/types/webhooks_list_query_request.rs b/agentmail-types/src/types/webhooks_list_query_request.rs new file mode 100644 index 0000000..6122846 --- /dev/null +++ b/agentmail-types/src/types/webhooks_list_query_request.rs @@ -0,0 +1,55 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Query parameters for list +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksListQueryRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ascending: Option, +} + +impl WebhooksListQueryRequest { + pub fn builder() -> WebhooksListQueryRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksListQueryRequestBuilder { + limit: Option, + page_token: Option, + ascending: Option, +} + +impl WebhooksListQueryRequestBuilder { + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn page_token(mut self, value: PageToken) -> Self { + self.page_token = Some(value); + self + } + + pub fn ascending(mut self, value: Ascending) -> Self { + self.ascending = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksListQueryRequest`]. + pub fn build(self) -> Result { + Ok(WebhooksListQueryRequest { + limit: self.limit, + page_token: self.page_token, + ascending: self.ascending, + }) + } +} + diff --git a/agentmail-types/src/types/webhooks_list_webhooks_response.rs b/agentmail-types/src/types/webhooks_list_webhooks_response.rs new file mode 100644 index 0000000..ca4d62e --- /dev/null +++ b/agentmail-types/src/types/webhooks_list_webhooks_response.rs @@ -0,0 +1,66 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksListWebhooksResponse { + #[serde(default)] + pub count: Count, + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, + /// Ordered by `created_at` descending. + #[serde(default)] + pub webhooks: Vec, +} + +impl WebhooksListWebhooksResponse { + pub fn builder() -> WebhooksListWebhooksResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksListWebhooksResponseBuilder { + count: Option, + limit: Option, + next_page_token: Option, + webhooks: Option>, +} + +impl WebhooksListWebhooksResponseBuilder { + pub fn count(mut self, value: Count) -> Self { + self.count = Some(value); + self + } + + pub fn limit(mut self, value: Limit) -> Self { + self.limit = Some(value); + self + } + + pub fn next_page_token(mut self, value: PageToken) -> Self { + self.next_page_token = Some(value); + self + } + + pub fn webhooks(mut self, value: Vec) -> Self { + self.webhooks = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksListWebhooksResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`count`](WebhooksListWebhooksResponseBuilder::count) + /// - [`webhooks`](WebhooksListWebhooksResponseBuilder::webhooks) + pub fn build(self) -> Result { + Ok(WebhooksListWebhooksResponse { + count: self.count.ok_or_else(|| BuildError::missing_field("count"))?, + limit: self.limit, + next_page_token: self.next_page_token, + webhooks: self.webhooks.ok_or_else(|| BuildError::missing_field("webhooks"))?, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_svix_id.rs b/agentmail-types/src/types/webhooks_svix_id.rs new file mode 100644 index 0000000..4ddd463 --- /dev/null +++ b/agentmail-types/src/types/webhooks_svix_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksSvixId(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_svix_signature.rs b/agentmail-types/src/types/webhooks_svix_signature.rs new file mode 100644 index 0000000..314eb74 --- /dev/null +++ b/agentmail-types/src/types/webhooks_svix_signature.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksSvixSignature(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_svix_timestamp.rs b/agentmail-types/src/types/webhooks_svix_timestamp.rs new file mode 100644 index 0000000..cd61809 --- /dev/null +++ b/agentmail-types/src/types/webhooks_svix_timestamp.rs @@ -0,0 +1,9 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksSvixTimestamp( + #[serde(deserialize_with = "crate::core::flexible_datetime::offset::deserialize")] + pub DateTime +); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_update_inbox_webhook_request.rs b/agentmail-types/src/types/webhooks_update_inbox_webhook_request.rs new file mode 100644 index 0000000..c70a306 --- /dev/null +++ b/agentmail-types/src/types/webhooks_update_inbox_webhook_request.rs @@ -0,0 +1,36 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Update an inbox-scoped webhook. It is fixed to its inbox, so only `event_types` can change. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksUpdateInboxWebhookRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub event_types: Option, +} + +impl WebhooksUpdateInboxWebhookRequest { + pub fn builder() -> WebhooksUpdateInboxWebhookRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksUpdateInboxWebhookRequestBuilder { + event_types: Option, +} + +impl WebhooksUpdateInboxWebhookRequestBuilder { + pub fn event_types(mut self, value: WebhooksUpdateWebhookEventTypes) -> Self { + self.event_types = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksUpdateInboxWebhookRequest`]. + pub fn build(self) -> Result { + Ok(WebhooksUpdateInboxWebhookRequest { + event_types: self.event_types, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_update_pod_webhook_request.rs b/agentmail-types/src/types/webhooks_update_pod_webhook_request.rs new file mode 100644 index 0000000..ee99f1b --- /dev/null +++ b/agentmail-types/src/types/webhooks_update_pod_webhook_request.rs @@ -0,0 +1,59 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Update a pod-scoped webhook. You can adjust which inboxes within the pod it listens to and replace +/// its `event_types`, but not the pod scope itself. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksUpdatePodWebhookRequest { + #[serde(flatten)] + pub webhooks_update_inbox_webhook_request_fields: WebhooksUpdateInboxWebhookRequest, + /// Inbox IDs to subscribe to the webhook. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_inbox_ids: Option, + /// Inbox IDs to unsubscribe from the webhook. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_inbox_ids: Option, +} + +impl WebhooksUpdatePodWebhookRequest { + pub fn builder() -> WebhooksUpdatePodWebhookRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksUpdatePodWebhookRequestBuilder { + webhooks_update_inbox_webhook_request_fields: Option, + add_inbox_ids: Option, + remove_inbox_ids: Option, +} + +impl WebhooksUpdatePodWebhookRequestBuilder { + pub fn webhooks_update_inbox_webhook_request_fields(mut self, value: WebhooksUpdateInboxWebhookRequest) -> Self { + self.webhooks_update_inbox_webhook_request_fields = Some(value); + self + } + + pub fn add_inbox_ids(mut self, value: InboxIds) -> Self { + self.add_inbox_ids = Some(value); + self + } + + pub fn remove_inbox_ids(mut self, value: InboxIds) -> Self { + self.remove_inbox_ids = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksUpdatePodWebhookRequest`]. + /// This method will fail if any of the following fields are not set: + /// - [`webhooks_update_inbox_webhook_request_fields`](WebhooksUpdatePodWebhookRequestBuilder::webhooks_update_inbox_webhook_request_fields) + pub fn build(self) -> Result { + Ok(WebhooksUpdatePodWebhookRequest { + webhooks_update_inbox_webhook_request_fields: self.webhooks_update_inbox_webhook_request_fields.ok_or_else(|| BuildError::missing_field("webhooks_update_inbox_webhook_request_fields"))?, + add_inbox_ids: self.add_inbox_ids, + remove_inbox_ids: self.remove_inbox_ids, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_update_webhook_event_types.rs b/agentmail-types/src/types/webhooks_update_webhook_event_types.rs new file mode 100644 index 0000000..a2c5b5b --- /dev/null +++ b/agentmail-types/src/types/webhooks_update_webhook_event_types.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksUpdateWebhookEventTypes(pub EventTypes); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_update_webhook_headers_request.rs b/agentmail-types/src/types/webhooks_update_webhook_headers_request.rs new file mode 100644 index 0000000..b3381da --- /dev/null +++ b/agentmail-types/src/types/webhooks_update_webhook_headers_request.rs @@ -0,0 +1,47 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +/// Set, replace, or remove custom delivery headers. Provide at least one of `headers` or +/// `remove_headers`. A header cannot be set and removed in the same request, regardless of casing. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct WebhooksUpdateWebhookHeadersRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Names of custom delivery headers to remove. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_headers: Option>, +} + +impl WebhooksUpdateWebhookHeadersRequest { + pub fn builder() -> WebhooksUpdateWebhookHeadersRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksUpdateWebhookHeadersRequestBuilder { + headers: Option, + remove_headers: Option>, +} + +impl WebhooksUpdateWebhookHeadersRequestBuilder { + pub fn headers(mut self, value: WebhooksWebhookHeaders) -> Self { + self.headers = Some(value); + self + } + + pub fn remove_headers(mut self, value: Vec) -> Self { + self.remove_headers = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksUpdateWebhookHeadersRequest`]. + pub fn build(self) -> Result { + Ok(WebhooksUpdateWebhookHeadersRequest { + headers: self.headers, + remove_headers: self.remove_headers, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_update_webhook_request.rs b/agentmail-types/src/types/webhooks_update_webhook_request.rs new file mode 100644 index 0000000..510257b --- /dev/null +++ b/agentmail-types/src/types/webhooks_update_webhook_request.rs @@ -0,0 +1,76 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksUpdateWebhookRequest { + /// Pod IDs to subscribe to the webhook. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_pod_ids: Option, + /// Pod IDs to unsubscribe from the webhook. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_pod_ids: Option, + /// Inbox IDs to subscribe to the webhook. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_inbox_ids: Option, + /// Inbox IDs to unsubscribe from the webhook. + #[serde(skip_serializing_if = "Option::is_none")] + pub remove_inbox_ids: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_types: Option, +} + +impl WebhooksUpdateWebhookRequest { + pub fn builder() -> WebhooksUpdateWebhookRequestBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksUpdateWebhookRequestBuilder { + add_pod_ids: Option, + remove_pod_ids: Option, + add_inbox_ids: Option, + remove_inbox_ids: Option, + event_types: Option, +} + +impl WebhooksUpdateWebhookRequestBuilder { + pub fn add_pod_ids(mut self, value: PodIds) -> Self { + self.add_pod_ids = Some(value); + self + } + + pub fn remove_pod_ids(mut self, value: PodIds) -> Self { + self.remove_pod_ids = Some(value); + self + } + + pub fn add_inbox_ids(mut self, value: InboxIds) -> Self { + self.add_inbox_ids = Some(value); + self + } + + pub fn remove_inbox_ids(mut self, value: InboxIds) -> Self { + self.remove_inbox_ids = Some(value); + self + } + + pub fn event_types(mut self, value: WebhooksUpdateWebhookEventTypes) -> Self { + self.event_types = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksUpdateWebhookRequest`]. + pub fn build(self) -> Result { + Ok(WebhooksUpdateWebhookRequest { + add_pod_ids: self.add_pod_ids, + remove_pod_ids: self.remove_pod_ids, + add_inbox_ids: self.add_inbox_ids, + remove_inbox_ids: self.remove_inbox_ids, + event_types: self.event_types, + }) + } +} + diff --git a/agentmail-types/src/types/webhooks_url.rs b/agentmail-types/src/types/webhooks_url.rs new file mode 100644 index 0000000..b38c8db --- /dev/null +++ b/agentmail-types/src/types/webhooks_url.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksUrl(pub String); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_webhook.rs b/agentmail-types/src/types/webhooks_webhook.rs new file mode 100644 index 0000000..88c469b --- /dev/null +++ b/agentmail-types/src/types/webhooks_webhook.rs @@ -0,0 +1,129 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksWebhook { + #[serde(default)] + pub webhook_id: WebhooksWebhookId, + #[serde(default)] + pub url: WebhooksUrl, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_types: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pod_ids: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inbox_ids: Option, + /// Secret for webhook signature verification. + #[serde(default)] + pub secret: String, + /// Webhook is enabled. + #[serde(default)] + pub enabled: bool, + /// Time at which webhook was last updated. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub updated_at: DateTime, + /// Time at which webhook was created. + #[serde(default)] + #[serde(with = "crate::core::flexible_datetime::offset")] + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +impl WebhooksWebhook { + pub fn builder() -> WebhooksWebhookBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksWebhookBuilder { + webhook_id: Option, + url: Option, + event_types: Option, + pod_ids: Option, + inbox_ids: Option, + secret: Option, + enabled: Option, + updated_at: Option>, + created_at: Option>, + client_id: Option, +} + +impl WebhooksWebhookBuilder { + pub fn webhook_id(mut self, value: WebhooksWebhookId) -> Self { + self.webhook_id = Some(value); + self + } + + pub fn url(mut self, value: WebhooksUrl) -> Self { + self.url = Some(value); + self + } + + pub fn event_types(mut self, value: EventTypes) -> Self { + self.event_types = Some(value); + self + } + + pub fn pod_ids(mut self, value: PodIds) -> Self { + self.pod_ids = Some(value); + self + } + + pub fn inbox_ids(mut self, value: InboxIds) -> Self { + self.inbox_ids = Some(value); + self + } + + pub fn secret(mut self, value: impl Into) -> Self { + self.secret = Some(value.into()); + self + } + + pub fn enabled(mut self, value: bool) -> Self { + self.enabled = Some(value); + self + } + + pub fn updated_at(mut self, value: DateTime) -> Self { + self.updated_at = Some(value); + self + } + + pub fn created_at(mut self, value: DateTime) -> Self { + self.created_at = Some(value); + self + } + + pub fn client_id(mut self, value: WebhooksClientId) -> Self { + self.client_id = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksWebhook`]. + /// This method will fail if any of the following fields are not set: + /// - [`webhook_id`](WebhooksWebhookBuilder::webhook_id) + /// - [`url`](WebhooksWebhookBuilder::url) + /// - [`secret`](WebhooksWebhookBuilder::secret) + /// - [`enabled`](WebhooksWebhookBuilder::enabled) + /// - [`updated_at`](WebhooksWebhookBuilder::updated_at) + /// - [`created_at`](WebhooksWebhookBuilder::created_at) + pub fn build(self) -> Result { + Ok(WebhooksWebhook { + webhook_id: self.webhook_id.ok_or_else(|| BuildError::missing_field("webhook_id"))?, + url: self.url.ok_or_else(|| BuildError::missing_field("url"))?, + event_types: self.event_types, + pod_ids: self.pod_ids, + inbox_ids: self.inbox_ids, + secret: self.secret.ok_or_else(|| BuildError::missing_field("secret"))?, + enabled: self.enabled.ok_or_else(|| BuildError::missing_field("enabled"))?, + updated_at: self.updated_at.ok_or_else(|| BuildError::missing_field("updated_at"))?, + created_at: self.created_at.ok_or_else(|| BuildError::missing_field("created_at"))?, + client_id: self.client_id, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_webhook_header_names_response.rs b/agentmail-types/src/types/webhooks_webhook_header_names_response.rs new file mode 100644 index 0000000..3063f9b --- /dev/null +++ b/agentmail-types/src/types/webhooks_webhook_header_names_response.rs @@ -0,0 +1,38 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)] +pub struct WebhooksWebhookHeaderNamesResponse { + /// Names of the custom delivery headers configured for this webhook. Header values are never returned. + #[serde(default)] + pub header_names: Vec, +} + +impl WebhooksWebhookHeaderNamesResponse { + pub fn builder() -> WebhooksWebhookHeaderNamesResponseBuilder { + ::default() + } +} + +#[derive(Clone, PartialEq, Default, Debug)] +#[non_exhaustive] +pub struct WebhooksWebhookHeaderNamesResponseBuilder { + header_names: Option>, +} + +impl WebhooksWebhookHeaderNamesResponseBuilder { + pub fn header_names(mut self, value: Vec) -> Self { + self.header_names = Some(value); + self + } + + /// Consumes the builder and constructs a [`WebhooksWebhookHeaderNamesResponse`]. + /// This method will fail if any of the following fields are not set: + /// - [`header_names`](WebhooksWebhookHeaderNamesResponseBuilder::header_names) + pub fn build(self) -> Result { + Ok(WebhooksWebhookHeaderNamesResponse { + header_names: self.header_names.ok_or_else(|| BuildError::missing_field("header_names"))?, + }) + } +} diff --git a/agentmail-types/src/types/webhooks_webhook_headers.rs b/agentmail-types/src/types/webhooks_webhook_headers.rs new file mode 100644 index 0000000..23ae907 --- /dev/null +++ b/agentmail-types/src/types/webhooks_webhook_headers.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct WebhooksWebhookHeaders(pub HashMap); \ No newline at end of file diff --git a/agentmail-types/src/types/webhooks_webhook_id.rs b/agentmail-types/src/types/webhooks_webhook_id.rs new file mode 100644 index 0000000..855dc90 --- /dev/null +++ b/agentmail-types/src/types/webhooks_webhook_id.rs @@ -0,0 +1,6 @@ +pub use crate::prelude::*; +#[allow(unused_imports)] +use super::*; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)] +pub struct WebhooksWebhookId(pub String); \ No newline at end of file diff --git a/bin/check-release-environment b/bin/check-release-environment deleted file mode 100644 index 1e951e9..0000000 --- a/bin/check-release-environment +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -errors=() - -lenErrors=${#errors[@]} - -if [[ lenErrors -gt 0 ]]; then - echo -e "Found the following errors in the release environment:\n" - - for error in "${errors[@]}"; do - echo -e "- $error\n" - done - - exit 1 -fi - -echo "The environment is ready to push releases!" diff --git a/cli/agentmail/custom.rs b/cli/agentmail/custom.rs new file mode 100644 index 0000000..75dc429 --- /dev/null +++ b/cli/agentmail/custom.rs @@ -0,0 +1,45 @@ +//! Custom command handlers. +//! +//! This file is yours to edit — add it to `.fernignore` so +//! `fern generate` will never overwrite your changes. +//! +//! The generated `main.rs` calls `custom::register(app)` at +//! startup, composing your commands into the CLI at compile time. +//! +//! Each handler receives an `AppContext`. Use `super::sdk::client(ctx)` +//! to get a fully-wired SDK client that inherits the CLI's auth, +//! retries, TLS, and global headers. Use `super::sdk::block_on(future)` +//! to run async SDK calls from synchronous handler context. +//! Types are available via `agentmail_sdk::api::*`. + +use fern_cli_sdk::app::CliApp; + +/// Register custom commands on the CLI app builder. +/// +/// Called from `main.rs` during startup. Uncomment the example +/// below and adapt it to your API to get started. +pub fn register(app: CliApp) -> CliApp { + // Example: typed SDK client usage with the co-generated SDK. + // + // use agentmail_sdk::api::*; + // + // let app = app.command( + // clap::Command::new("get-plant") + // .about("Fetch a plant by its ID") + // .arg(clap::Arg::new("plant-id").required(true)), + // // `command` takes a `CliCommandHandler`, which is a boxed + // // `Fn(&ArgMatches, &dyn Any)`. `OpenApiBinding::handler` wraps + // // your closure and downcasts the context to `&AppContext`, so a + // // bare closure here will not compile. + // fern_cli_sdk::openapi::OpenApiBinding::handler(|matches, ctx| { + // let plant_id = matches.get_one::("plant-id").unwrap(); + // let client = super::sdk::client(ctx); + // let plant = super::sdk::block_on( + // client.plants.get_plant(plant_id, None), + // )?; + // println!("{}", serde_json::to_string_pretty(&plant).unwrap()); + // Ok(()) + // }), + // ); + app +} diff --git a/cli/agentmail/main.rs b/cli/agentmail/main.rs new file mode 100644 index 0000000..e05184b --- /dev/null +++ b/cli/agentmail/main.rs @@ -0,0 +1,23 @@ +// Auto-generated by @fern-api/cli-generator's copySpecs step. +// Edit the SDK template / generator if you need to change the shape. + +mod custom; +mod sdk; + +use fern_cli_sdk::app::CliApp; +use fern_cli_sdk::openapi::OpenApiBinding; +use fern_cli_sdk::auth::{BearerAuth}; + +fn main() { + let app = CliApp::new("agentmail") + .auth(BearerAuth::new("BearerAuth").env("AGENTMAIL_API_KEY")) + .auth(BearerAuth::new("TokenAuth").env("AGENTMAIL_TOKEN")) + .binding( + OpenApiBinding::new() + .spec(include_str!("openapi0.json")) + ); + + let app = custom::register(app); + + app.run() +} diff --git a/cli/agentmail/openapi0.json b/cli/agentmail/openapi0.json new file mode 100644 index 0000000..ce6da31 --- /dev/null +++ b/cli/agentmail/openapi0.json @@ -0,0 +1 @@ +{"openapi":"3.0.1","info":{"title":"AgentMail","version":""},"paths":{"/v0/inboxes":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes list\n```","operationId":"inboxes_list","tags":["Inboxes"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes create --display-name \"My Agent\" --username myagent --domain agentmail.to\n```","operationId":"inboxes_create","tags":["Inboxes"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest","nullable":true}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes get --inbox-id \n```","operationId":"inboxes_get","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail inboxes update --inbox-id --display-name \"Updated Name\"\n```","operationId":"inboxes_update","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"description":"Expects an object; provide at least one of `display_name` or `metadata`.","required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesUpdateInboxRequest"}}}},"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes delete --inbox-id \n```","operationId":"inboxes_delete","tags":["Inboxes"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods":{"get":{"description":"**CLI:**\n```bash\nagentmail pods list\n```","operationId":"pods_list","tags":["Pods"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsListPodsResponse"}}}}},"summary":"List Pods","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods create --client-id my-pod\n```","operationId":"pods_create","tags":["Pods"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsCreatePodRequest"}}}},"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods get --pod-id \n```","operationId":"pods_get","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/podsPod"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"get"},"delete":{"description":"**CLI:**\n```bash\nagentmail pods delete --pod-id \n```","operationId":"pods_delete","tags":["Pods"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Pod","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods"],"x-fern-sdk-method-name":"delete"}},"/v0/webhooks":{"get":{"description":"**CLI:**\n```bash\nagentmail webhooks list\n```","operationId":"webhooks_list","tags":["Webhooks"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksListWebhooksResponse"}}}}},"summary":"List Webhooks","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail webhooks create --url https://example.com/webhook --event-types message.received\n```","operationId":"webhooks_create","tags":["Webhooks"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksCreateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"create"}},"/v0/webhooks/{webhook_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail webhooks get --webhook-id \n```","operationId":"webhooks_get","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Update inbox or pod subscriptions, or replace the webhook's `event_types` in full when you pass a\nnon-empty `event_types` array (see request field docs). Inbox and pod changes use add/remove lists.\n\n**CLI:**\n```bash\nagentmail webhooks update --webhook-id --add-inbox-ids \n```","operationId":"webhooks_update","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail webhooks delete --webhook-id \n```","operationId":"webhooks_delete","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"delete"}},"/v0/webhooks/{webhook_id}/headers":{"get":{"description":"List the names of custom HTTP headers included with deliveries to this webhook. Header values are\nwrite-only and are never returned.","operationId":"webhooks_getHeaders","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhookHeaderNamesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook Headers","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"get-headers"},"patch":{"description":"Atomically set, replace, or remove custom HTTP headers included with deliveries to this webhook.\nHeader values remain write-only.","operationId":"webhooks_updateHeaders","tags":["Webhooks"],"parameters":[{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook Headers","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookHeadersRequest"}}}},"x-fern-sdk-group-name":["webhooks"],"x-fern-sdk-method-name":"update-headers"}},"/v0/agent/sign-up":{"post":{"description":"Create a new agent organization with an inbox and API key. This endpoint is for signing up for the first time. If you've already signed up, you're all set — just use your existing API key.\n\nA 6-digit OTP is sent to the human's email for verification.\n\nThis endpoint is idempotent. Calling it again with the same `human_email` will rotate the API key and resend the OTP if expired.\n\nThe returned API key has limited permissions until the organization is verified via the verify endpoint.\n\n**CLI:**\n```bash\nagentmail agent sign-up --human-email user@example.com --username my-agent\n```","operationId":"agent_signUp","tags":["Agent"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentSignupResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Sign Up","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentSignupRequest"}}}},"x-fern-sdk-group-name":["agent"],"x-fern-sdk-method-name":"sign-up"}},"/v0/agent/verify":{"post":{"description":"Verify an agent organization using the 6-digit OTP sent to the human's email during sign-up.\n\nOn success, the organization is upgraded from `agent_unverified` to `agent_verified`, the send allowlist is removed, and free plan entitlements are applied.\n\nThe OTP expires after 24 hours and allows a maximum of 10 attempts. If you run into any difficulties receiving the OTP code, you can also create an account on [console.agentmail.to](https://console.agentmail.to) using the human email address you provided to verify your account.\n\n**CLI:**\n```bash\nagentmail agent verify --otp-code 123456\n```","operationId":"agent_verify","tags":["Agent"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVerifyResponse"}}}}},"summary":"Verify","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AgentVerifyRequest"}}}},"x-fern-sdk-group-name":["agent"],"x-fern-sdk-method-name":"verify"}},"/v0/api-keys":{"get":{"description":"**CLI:**\n```bash\nagentmail api-keys list\n```","operationId":"apiKeys_list","tags":["ApiKeys"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail api-keys create --name \"My Key\"\n```","operationId":"apiKeys_create","tags":["ApiKeys"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"create"}},"/v0/api-keys/{api_key_id}":{"delete":{"description":"**CLI:**\n```bash\nagentmail api-keys delete --api-key-id \n```","operationId":"apiKeys_delete","tags":["ApiKeys"],"parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"delete"}},"/v0/api-keys/public-keys":{"get":{"description":"List only public-key credentials visible to the bearer caller's scope.\nBearer credentials are never returned, even though both credential types\nshare storage and pagination indexes. Requires `api_key_read`.","operationId":"apiKeys_listPublicKeys","tags":["ApiKeys"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListPublicKeysResponse"}}}}},"summary":"List Public-Key Credentials","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"list-public-keys"},"post":{"description":"Register a public P-256 JWK using an existing AgentMail bearer API key\nwith `api_key_create`. Re-registering the same JWK creates a new\ncredential ID; it does not replace or recover an earlier credential.\nThe private key must never be sent to AgentMail.","operationId":"apiKeys_createPublicKey","tags":["ApiKeys"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicKeyCredential"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Register Public-Key Credential","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreatePublicKeyRequest"}}}},"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"create-public-key"}},"/v0/api-keys/public-keys/{api_key_id}":{"patch":{"description":"Rename the credential. All security-relevant fields are immutable.\nRequires `api_key_update`.","operationId":"apiKeys_updatePublicKeyName","tags":["ApiKeys"],"parameters":[{"name":"api_key_id","in":"path","description":"Public-key credential ID returned by registration.","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublicKeyCredential"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Rename Public-Key Credential","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdatePublicKeyNameRequest"}}}},"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"update-public-key-name"},"delete":{"description":"Permanently revoke one public-key credential. This hard-deletes the\ncredential; repeating the request returns not found. Requires\n`api_key_delete`.","operationId":"apiKeys_revokePublicKey","tags":["ApiKeys"],"parameters":[{"name":"api_key_id","in":"path","description":"Public-key credential ID returned by registration.","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Revoke Public-Key Credential","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"revoke-public-key"}},"/v0/api-keys/public-keys/agentid-sign-in/revoke-all":{"post":{"description":"Invalidate every current public-key credential in the caller's\norganization by advancing its AgentID key generation. The caller must be\norganization-scoped and either have `api_key_delete` or, for a verified\nself-serve agent organization, use an unrestricted unmanaged bearer\ncredential. No request body is accepted.\n\n`Idempotency-Key` is required and must be a UUID. Reusing the same UUID\nreturns the original permanent receipt without advancing the generation\nagain. A new UUID performs a new generation advance.","operationId":"apiKeys_revokeAllAgentIdSignInKeys","tags":["ApiKeys"],"parameters":[{"name":"Idempotency-Key","in":"header","description":"Required UUID identifying this revoke-all operation permanently.","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RevokeAllAgentIdSignInKeysResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Revoke All AgentID Sign-In Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["apiKeys"],"x-fern-sdk-method-name":"revoke-all-agent-id-sign-in-keys"}},"/v0/auth/me":{"get":{"description":"Returns the identity and scope of the authenticated credential. Useful when a client holds a pod-scoped or inbox-scoped API key and needs to discover the parent organization, pod, or inbox without prior knowledge.\n\n**CLI:**\n```bash\nagentmail auth me\n```","operationId":"auth_me","tags":["Auth"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Identity"}}}}},"summary":"Who Am I","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["auth"],"x-fern-sdk-method-name":"me"}},"/v0/domains":{"get":{"description":"**CLI:**\n```bash\nagentmail domains list\n```","operationId":"domains_list","tags":["Domains"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail domains create --domain example.com\n```","operationId":"domains_create","tags":["Domains"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"create"}},"/v0/domains/{domain_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail domains get --domain-id \n```","operationId":"domains_get","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail domains update --domain-id \n```","operationId":"domains_update","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDomainRequest"}}}},"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail domains delete --domain-id \n```","operationId":"domains_delete","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"delete"}},"/v0/domains/{domain_id}/zone-file":{"get":{"description":"**CLI:**\n```bash\nagentmail domains get-zone-file --domain-id \n```","operationId":"domains_getZoneFile","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Zone File","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"get-zone-file"}},"/v0/domains/{domain_id}/verify":{"post":{"description":"**CLI:**\n```bash\nagentmail domains verify --domain-id \n```","operationId":"domains_verify","tags":["Domains"],"parameters":[{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Verify Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["domains"],"x-fern-sdk-method-name":"verify"}},"/v0/drafts":{"get":{"description":"**CLI:**\n```bash\nagentmail drafts list\n```","operationId":"drafts_list","tags":["Drafts"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"list"}},"/v0/drafts/{draft_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail drafts get --draft-id \n```","operationId":"drafts_get","tags":["Drafts"],"parameters":[{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"get"}},"/v0/drafts/{draft_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail drafts get-attachment --draft-id --attachment-id \n```","operationId":"drafts_getAttachment","tags":["Drafts"],"parameters":[{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["drafts"],"x-fern-sdk-method-name":"get-attachment"}},"/v0/inboxes/{inbox_id}/api-keys":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes api-keys list --inbox-id \n```","operationId":"inboxes_apiKeys_list","tags":["InboxesApiKeys"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","apiKeys"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes api-keys create --inbox-id --name \"My Key\"\n```","operationId":"inboxes_apiKeys_create","tags":["InboxesApiKeys"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"x-fern-sdk-group-name":["inboxes","apiKeys"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/api-keys/{api_key_id}":{"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes api-keys delete --inbox-id --api-key-id \n```","operationId":"inboxes_apiKeys_delete","tags":["InboxesApiKeys"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"api_key_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","apiKeys"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/drafts":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes drafts list --inbox-id \n```","operationId":"inboxes_drafts_list","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"list"},"post":{"description":"Create a draft. Supply `in_reply_to` to create a reply draft (with\n`reply_all` to address the whole thread), whose recipients, subject, and\nthreading are derived from the referenced message, or `forward_of` to\ncreate a forward draft, which derives the subject, threading, and\nforwarded content from the source but keeps recipients caller-supplied.\n\n**CLI:**\n```bash\nagentmail inboxes drafts create --inbox-id --to recipient@example.com --subject \"Draft subject\" --text \"Draft body\"\n```","operationId":"inboxes_drafts_create","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes drafts get --inbox-id --draft-id \n```","operationId":"inboxes_drafts_get","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Edit fields on an existing draft. Passing `null` clears a field (or `[]`\nfor a recipient field); `send_at: null` un-schedules a scheduled draft.\nA draft that is already being sent cannot be edited.\n\n**CLI:**\n```bash\nagentmail inboxes drafts update --inbox-id --draft-id --subject \"Updated subject\"\n```","operationId":"inboxes_drafts_update","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDraftRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes drafts delete --inbox-id --draft-id \n```","operationId":"inboxes_drafts_delete","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes drafts get-attachment --inbox-id --draft-id --attachment-id \n```","operationId":"inboxes_drafts_getAttachment","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"get-attachment"}},"/v0/inboxes/{inbox_id}/drafts/{draft_id}/send":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes drafts send --inbox-id --draft-id \n```","operationId":"inboxes_drafts_send","tags":["InboxesDrafts"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","drafts"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/events":{"get":{"description":"List label change events for an inbox. Returns events in reverse chronological order by default. Use for IMAP UID projection or audit logging.\n\n**CLI:**\n```bash\nagentmail inboxes events list --inbox-id \n```","operationId":"inboxes_events_list","tags":["InboxesEvents"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListInboxEventsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Inbox Events","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","events"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/lists/{direction}/{type}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes lists list --inbox-id --direction --type \n```","operationId":"inboxes_lists_list","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListListEntriesResponse"}}}}},"summary":"List Entries","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","lists"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail inboxes lists create --inbox-id --direction --type --entry user@example.com\n```","operationId":"inboxes_lists_create","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListEntryRequest"}}}},"x-fern-sdk-group-name":["inboxes","lists"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/lists/{direction}/{type}/{entry}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes lists get --inbox-id --direction --type --entry \n```","operationId":"inboxes_lists_get","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","lists"],"x-fern-sdk-method-name":"get"},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes lists delete --inbox-id --direction --type --entry \n```","operationId":"inboxes_lists_delete","tags":["InboxesLists"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","lists"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/messages":{"get":{"description":"Lists messages in the inbox, most recent first. Pass `from`, `to`, or\n`subject` to filter by substring. Filtered requests are served by\nsearch, which caps `limit` at 100. For relevance-ranked full-text\nsearch across sender, recipients, subject, and message body, use\n`Search Messages`.\n\n**CLI:**\n```bash\nagentmail inboxes messages list --inbox-id \n```","operationId":"inboxes_messages_list","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}},{"name":"from","in":"query","description":"Filter to messages whose sender contains this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"to","in":"query","description":"Filter to messages whose recipients (to, cc, or bcc) contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"subject","in":"query","description":"Filter to messages whose subject contains this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListMessagesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/messages/search":{"get":{"description":"Full-text search across messages in the inbox, ranked by relevance. The\nquery is matched against the sender, recipients, and subject (substring)\nand the message body (tokenized full text). Spam, trash, blocked, and\nunauthenticated messages are always excluded. `limit` cannot exceed 100.","operationId":"inboxes_messages_search","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"q","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Query"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchMessagesResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Search Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"search"}},"/v0/inboxes/{inbox_id}/messages/{message_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes messages get --inbox-id --message-id \n```","operationId":"inboxes_messages_get","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Message"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail inboxes messages update --inbox-id --message-id --add-labels read --remove-labels unread\n```","operationId":"inboxes_messages_update","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"update"},"delete":{"description":"Permanently deletes a message.\n\n**CLI:**\n```bash\nagentmail inboxes messages delete --inbox-id --message-id \n```","operationId":"inboxes_messages_delete","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/messages/batch-get":{"post":{"description":"Fetch metadata for up to 500 messages in one request. Missing or\nrestricted IDs are silently omitted; compare `count` against `limit`\nto detect misses.\n\n**CLI:**\n```bash\nagentmail inboxes messages batch-get --inbox-id --message-ids --message-ids \n```","operationId":"inboxes_messages_batchGet","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchGetMessagesResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Batch Get Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchGetMessagesRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"batch-get"}},"/v0/inboxes/{inbox_id}/messages/batch-update":{"post":{"description":"Apply one label change to up to 50 messages in a single request. The\nsame add_labels and remove_labels apply to every message id, and at\nleast one of them must be provided. The update is atomic: either all\nresolved messages are updated or none are. Missing or restricted ids\nare silently excluded; compare `count` against `limit` to detect\nexclusions.\n\n**CLI:**\n```bash\nagentmail inboxes messages batch-update --inbox-id --message-ids --message-ids --add-labels read --remove-labels unread\n```","operationId":"inboxes_messages_batchUpdate","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchUpdateMessagesResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Batch Update Messages","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchUpdateMessagesRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"batch-update"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes messages get-attachment --inbox-id --message-id --attachment-id \n```","operationId":"inboxes_messages_getAttachment","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"get-attachment"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/raw":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes messages get-raw --inbox-id --message-id \n```","operationId":"inboxes_messages_getRaw","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RawMessageResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Raw Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"get-raw"}},"/v0/inboxes/{inbox_id}/messages/send":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes messages send --inbox-id --to recipient@example.com --subject \"Hello\" --text \"Body\"\n```","operationId":"inboxes_messages_send","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Send Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"send"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes messages reply --inbox-id --message-id --text \"Reply text\"\n```","operationId":"inboxes_messages_reply","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply To Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyToMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/reply-all":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes messages reply-all --inbox-id --message-id --text \"Reply text\"\n```","operationId":"inboxes_messages_reply-all","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Reply All Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReplyAllMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"reply-all"}},"/v0/inboxes/{inbox_id}/messages/{message_id}/forward":{"post":{"description":"**CLI:**\n```bash\nagentmail inboxes messages forward --inbox-id --message-id --to recipient@example.com\n```","operationId":"inboxes_messages_forward","tags":["InboxesMessages"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"message_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/MessageId"}},{"name":"Idempotency-Key","in":"header","required":false,"description":"Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes.","schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Forward Message","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SendMessageRequest"}}}},"x-fern-sdk-group-name":["inboxes","messages"],"x-fern-sdk-method-name":"forward"}},"/v0/inboxes/{inbox_id}/metrics/events":{"get":{"description":"Counts of email events (sent, delivered, bounced, etc.) over time for\nthe inbox. Defaults to the last 24 hours; `start` must be within the\nlast 90 days, and a future `end` is clamped to now. Omit `period` for\nindividual event counts, or set it to sum counts into buckets of that\nmany seconds.\n\n**CLI:**\n```bash\nagentmail inboxes metrics query-events --inbox-id \n```","operationId":"inboxes_metrics_queryEvents","tags":["InboxesMetrics"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Events","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","metrics"],"x-fern-sdk-method-name":"query-events"}},"/v0/inboxes/{inbox_id}/metrics/usage":{"get":{"description":"Cumulative usage series for the inbox. Each point is the running total\nof the usage type at that timestamp, not the change within the bucket.\nInbox-scoped queries carry `storage_bytes`, `message_count`, and\n`thread_count`; requested types that don't apply to the scope are\nignored. Defaults to the last 24 hours; `start` must be within the\nlast 90 days, and a future `end` is clamped to now. The range divided\nby `period` must not exceed 1000 buckets.","operationId":"inboxes_metrics_queryUsage","tags":["InboxesMetrics"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"usage_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/UsageTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryUsageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Usage","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","metrics"],"x-fern-sdk-method-name":"query-usage"}},"/v0/inboxes/{inbox_id}/threads":{"get":{"description":"Lists threads in the inbox, most recent first. Pass `senders`,\n`recipients`, or `subject` to filter by substring. Filtered requests are\nserved by search, which caps `limit` at 100. For relevance-ranked\nfull-text search, use `Search Threads`.\n\n**CLI:**\n```bash\nagentmail inboxes threads list --inbox-id \n```","operationId":"inboxes_threads_list","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}},{"name":"senders","in":"query","description":"Filter to threads whose senders contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"recipients","in":"query","description":"Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"subject","in":"query","description":"Filter to threads whose subject contains this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"list"}},"/v0/inboxes/{inbox_id}/threads/search":{"get":{"description":"Full-text search across threads in the inbox, ranked by relevance. The\nquery is matched against senders, recipients, and subject (substring)\nand the message body (tokenized full text). Spam, trash, blocked, and\nunauthenticated threads are always excluded. `limit` cannot exceed 100.","operationId":"inboxes_threads_search","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"q","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Query"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchThreadsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Search Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"search"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes threads get --inbox-id --thread-id \n```","operationId":"inboxes_threads_get","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages.","operationId":"inboxes_threads_update","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadRequest"}}}},"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"update"},"delete":{"description":"Permanently deletes a thread and all of its messages.\n\n**CLI:**\n```bash\nagentmail inboxes threads delete --inbox-id --thread-id \n```","operationId":"inboxes_threads_delete","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes threads get-attachment --inbox-id --thread-id --attachment-id \n```","operationId":"inboxes_threads_getAttachment","tags":["InboxesThreads"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","threads"],"x-fern-sdk-method-name":"get-attachment"}},"/v0/inboxes/{inbox_id}/webhooks":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes webhooks list --inbox-id \n```","operationId":"inboxes_webhooks_list","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksListWebhooksResponse"}}}}},"summary":"List Webhooks","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"list"},"post":{"description":"Create a webhook scoped to this inbox.\n\n**CLI:**\n```bash\nagentmail inboxes webhooks create --inbox-id --url https://example.com/webhook --event-types message.received\n```","operationId":"inboxes_webhooks_create","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksCreateInboxWebhookRequest"}}}},"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"create"}},"/v0/inboxes/{inbox_id}/webhooks/{webhook_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail inboxes webhooks get --inbox-id --webhook-id \n```","operationId":"inboxes_webhooks_get","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail inboxes webhooks update --inbox-id --webhook-id --event-types message.received\n```","operationId":"inboxes_webhooks_update","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateInboxWebhookRequest"}}}},"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail inboxes webhooks delete --inbox-id --webhook-id \n```","operationId":"inboxes_webhooks_delete","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"delete"}},"/v0/inboxes/{inbox_id}/webhooks/{webhook_id}/headers":{"get":{"description":"List the names of custom HTTP headers included with deliveries to this inbox-scoped webhook.\nHeader values are write-only and are never returned.","operationId":"inboxes_webhooks_getHeaders","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhookHeaderNamesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook Headers","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"get-headers"},"patch":{"description":"Atomically set, replace, or remove custom HTTP headers included with deliveries to this\ninbox-scoped webhook. Header values remain write-only.","operationId":"inboxes_webhooks_updateHeaders","tags":["InboxesWebhooks"],"parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook Headers","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookHeadersRequest"}}}},"x-fern-sdk-group-name":["inboxes","webhooks"],"x-fern-sdk-method-name":"update-headers"}},"/v0/lists/{direction}/{type}":{"get":{"description":"**CLI:**\n```bash\nagentmail lists list --direction --type \n```","operationId":"lists_list","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListListEntriesResponse"}}}}},"summary":"List Entries","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["lists"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail lists create --direction --type --entry user@example.com\n```","operationId":"lists_create","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListEntry"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListEntryRequest"}}}},"x-fern-sdk-group-name":["lists"],"x-fern-sdk-method-name":"create"}},"/v0/lists/{direction}/{type}/{entry}":{"get":{"description":"**CLI:**\n```bash\nagentmail lists get --direction --type --entry \n```","operationId":"lists_get","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListEntry"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["lists"],"x-fern-sdk-method-name":"get"},"delete":{"description":"**CLI:**\n```bash\nagentmail lists delete --direction --type --entry \n```","operationId":"lists_delete","tags":["Lists"],"parameters":[{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["lists"],"x-fern-sdk-method-name":"delete"}},"/v0/metrics/events":{"get":{"description":"Counts of email events (sent, delivered, bounced, etc.) over time for\nthe organization. Defaults to the last 24 hours; `start` must be within\nthe last 90 days, and a future `end` is clamped to now. Omit `period`\nfor individual event counts, or set it to sum counts into buckets of\nthat many seconds.\n\n**CLI:**\n```bash\nagentmail metrics query-events\n```","operationId":"metrics_queryEvents","tags":["Metrics"],"parameters":[{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Events","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["metrics"],"x-fern-sdk-method-name":"query-events"}},"/v0/metrics/usage":{"get":{"description":"Cumulative usage series for the organization. Each point is the running\ntotal of the usage type at that timestamp, not the change within the\nbucket. Defaults to the last 24 hours; `start` must be within the last\n90 days, and a future `end` is clamped to now. The range divided by\n`period` must not exceed 1000 buckets.","operationId":"metrics_queryUsage","tags":["Metrics"],"parameters":[{"name":"usage_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/UsageTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryUsageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Usage","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["metrics"],"x-fern-sdk-method-name":"query-usage"}},"/v0/organizations":{"get":{"description":"Returns the organization for the authenticated API key (usage limits, counts, and billing metadata).\n\n**CLI:**\n```bash\nagentmail organizations get\n```","operationId":"organizations_get","tags":["Organizations"],"parameters":[],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Organization"}}}}},"summary":"Get Organization","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["organizations"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/api-keys":{"get":{"description":"**CLI:**\n```bash\nagentmail pods api-keys list --pod-id \n```","operationId":"pods_apiKeys_list","tags":["PodsApiKeys"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListApiKeysResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List API Keys","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","apiKeys"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods api-keys create --pod-id --name \"My Key\"\n```","operationId":"pods_apiKeys_create","tags":["PodsApiKeys"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}}},"x-fern-sdk-group-name":["pods","apiKeys"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/api-keys/{api_key_id}":{"delete":{"description":"**CLI:**\n```bash\nagentmail pods api-keys delete --pod-id --api-key-id \n```","operationId":"pods_apiKeys_delete","tags":["PodsApiKeys"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"api_key_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ApiKeyId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete API Key","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","apiKeys"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/domains":{"get":{"description":"**CLI:**\n```bash\nagentmail pods domains list --pod-id \n```","operationId":"pods_domains_list","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDomainsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Domains","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods domains create --pod-id --domain example.com\n```","operationId":"pods_domains_create","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDomainRequest"}}}},"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/domains/{domain_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods domains get --pod-id --domain-id \n```","operationId":"pods_domains_get","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail pods domains update --pod-id --domain-id \n```","operationId":"pods_domains_update","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Domain"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDomainRequest"}}}},"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail pods domains delete --pod-id --domain-id \n```","operationId":"pods_domains_delete","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/domains/{domain_id}/zone-file":{"get":{"description":"**CLI:**\n```bash\nagentmail pods domains get-zone-file --pod-id --domain-id \n```","operationId":"pods_domains_getZoneFile","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Zone File","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"get-zone-file"}},"/v0/pods/{pod_id}/domains/{domain_id}/verify":{"post":{"description":"**CLI:**\n```bash\nagentmail pods domains verify --pod-id --domain-id \n```","operationId":"pods_domains_verify","tags":["PodsDomains"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"domain_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DomainId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Verify Domain","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","domains"],"x-fern-sdk-method-name":"verify"}},"/v0/pods/{pod_id}/drafts":{"get":{"description":"**CLI:**\n```bash\nagentmail pods drafts list --pod-id \n```","operationId":"pods_drafts_list","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListDraftsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Drafts","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/drafts/{draft_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods drafts get --pod-id --draft-id \n```","operationId":"pods_drafts_get","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Draft"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Draft","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"get"}},"/v0/pods/{pod_id}/drafts/{draft_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods drafts get-attachment --pod-id --draft-id --attachment-id \n```","operationId":"pods_drafts_getAttachment","tags":["PodsDrafts"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"draft_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/DraftId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","drafts"],"x-fern-sdk-method-name":"get-attachment"}},"/v0/pods/{pod_id}/inboxes":{"get":{"description":"**CLI:**\n```bash\nagentmail pods inboxes list --pod-id \n```","operationId":"pods_inboxes_list","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesListInboxesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Inboxes","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods inboxes create --pod-id --username myagent --domain example.com\n```","operationId":"pods_inboxes_create","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Create Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesCreateInboxRequest"}}}},"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/inboxes/{inbox_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods inboxes get --pod-id --inbox-id \n```","operationId":"pods_inboxes_get","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail pods inboxes update --pod-id --inbox-id \n```","operationId":"pods_inboxes_update","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesInbox"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/inboxesUpdateInboxRequest"}}}},"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail pods inboxes delete --pod-id --inbox-id \n```","operationId":"pods_inboxes_delete","tags":["PodsInboxes"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"inbox_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/inboxesInboxId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Inbox","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","inboxes"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/lists/{direction}/{type}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods lists list --pod-id --direction --type \n```","operationId":"pods_lists_list","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListListEntriesResponse"}}}}},"summary":"List Entries","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","lists"],"x-fern-sdk-method-name":"list"},"post":{"description":"**CLI:**\n```bash\nagentmail pods lists create --pod-id --direction --type --entry user@example.com\n```","operationId":"pods_lists_create","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateListEntryRequest"}}}},"x-fern-sdk-group-name":["pods","lists"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/lists/{direction}/{type}/{entry}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods lists get --pod-id --direction --type --entry \n```","operationId":"pods_lists_get","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PodListEntry"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","lists"],"x-fern-sdk-method-name":"get"},"delete":{"description":"**CLI:**\n```bash\nagentmail pods lists delete --pod-id --direction --type --entry \n```","operationId":"pods_lists_delete","tags":["PodsLists"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"direction","in":"path","required":true,"schema":{"$ref":"#/components/schemas/Direction"}},{"name":"type","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ListType"}},{"name":"entry","in":"path","description":"Email address or domain.","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete List Entry","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","lists"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/metrics/events":{"get":{"description":"Counts of email events (sent, delivered, bounced, etc.) over time for\nthe pod. Defaults to the last 24 hours; `start` must be within the last\n90 days, and a future `end` is clamped to now. Omit `period` for\nindividual event counts, or set it to sum counts into buckets of that\nmany seconds.\n\n**CLI:**\n```bash\nagentmail pods metrics query-events --pod-id \n```","operationId":"pods_metrics_queryEvents","tags":["PodsMetrics"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"event_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricEventTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryMetricsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Events","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","metrics"],"x-fern-sdk-method-name":"query-events"}},"/v0/pods/{pod_id}/metrics/usage":{"get":{"description":"Cumulative usage series for the pod. Each point is the running total of\nthe usage type at that timestamp, not the change within the bucket.\nPod-scoped queries carry every usage type except `pod_count`; requested\ntypes that don't apply to the scope are ignored. Defaults to the last\n24 hours; `start` must be within the last 90 days, and a future `end`\nis clamped to now. The range divided by `period` must not exceed 1000\nbuckets.","operationId":"pods_metrics_queryUsage","tags":["PodsMetrics"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"usage_types","in":"query","required":false,"schema":{"$ref":"#/components/schemas/UsageTypes","nullable":true}},{"name":"start","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Start","nullable":true}},{"name":"end","in":"query","required":false,"schema":{"$ref":"#/components/schemas/End","nullable":true}},{"name":"period","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Period","nullable":true}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/MetricLimit","nullable":true}},{"name":"descending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Descending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryUsageResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Query Usage","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","metrics"],"x-fern-sdk-method-name":"query-usage"}},"/v0/pods/{pod_id}/threads":{"get":{"description":"Lists threads in the pod, most recent first. Pass `senders`,\n`recipients`, or `subject` to filter by substring. Filtered requests are\nserved by search, which caps `limit` at 100. For relevance-ranked\nfull-text search, use `Search Threads`.\n\n**CLI:**\n```bash\nagentmail pods threads list --pod-id \n```","operationId":"pods_threads_list","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}},{"name":"senders","in":"query","description":"Filter to threads whose senders contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"recipients","in":"query","description":"Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"subject","in":"query","description":"Filter to threads whose subject contains this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"list"}},"/v0/pods/{pod_id}/threads/search":{"get":{"description":"Full-text search across threads in the pod, ranked by relevance. The\nquery is matched against senders, recipients, and subject (substring)\nand the message body (tokenized full text). Spam, trash, blocked, and\nunauthenticated threads are always excluded. `limit` cannot exceed 100.","operationId":"pods_threads_search","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"q","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Query"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchThreadsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Search Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"search"}},"/v0/pods/{pod_id}/threads/{thread_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods threads get --pod-id --thread-id \n```","operationId":"pods_threads_get","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages.","operationId":"pods_threads_update","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadRequest"}}}},"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"update"},"delete":{"description":"Permanently deletes a thread and all of its messages.\n\n**CLI:**\n```bash\nagentmail pods threads delete --pod-id --thread-id \n```","operationId":"pods_threads_delete","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods threads get-attachment --pod-id --thread-id --attachment-id \n```","operationId":"pods_threads_getAttachment","tags":["PodsThreads"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","threads"],"x-fern-sdk-method-name":"get-attachment"}},"/v0/pods/{pod_id}/webhooks":{"get":{"description":"**CLI:**\n```bash\nagentmail pods webhooks list --pod-id \n```","operationId":"pods_webhooks_list","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksListWebhooksResponse"}}}}},"summary":"List Webhooks","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"list"},"post":{"description":"Create a webhook scoped to this pod.\n\n**CLI:**\n```bash\nagentmail pods webhooks create --pod-id --url https://example.com/webhook --event-types message.received\n```","operationId":"pods_webhooks_create","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}}},"summary":"Create Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksCreatePodWebhookRequest"}}}},"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"create"}},"/v0/pods/{pod_id}/webhooks/{webhook_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail pods webhooks get --pod-id --webhook-id \n```","operationId":"pods_webhooks_get","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"get"},"patch":{"description":"**CLI:**\n```bash\nagentmail pods webhooks update --pod-id --webhook-id --add-inbox-ids \n```","operationId":"pods_webhooks_update","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhook"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdatePodWebhookRequest"}}}},"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"update"},"delete":{"description":"**CLI:**\n```bash\nagentmail pods webhooks delete --pod-id --webhook-id \n```","operationId":"pods_webhooks_delete","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Webhook","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"delete"}},"/v0/pods/{pod_id}/webhooks/{webhook_id}/headers":{"get":{"description":"List the names of custom HTTP headers included with deliveries to this pod-scoped webhook.\nHeader values are write-only and are never returned.","operationId":"pods_webhooks_getHeaders","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksWebhookHeaderNamesResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Webhook Headers","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"get-headers"},"patch":{"description":"Atomically set, replace, or remove custom HTTP headers included with deliveries to this\npod-scoped webhook. Header values remain write-only.","operationId":"pods_webhooks_updateHeaders","tags":["PodsWebhooks"],"parameters":[{"name":"pod_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/podsPodId"}},{"name":"webhook_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/webhooksWebhookId"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Webhook Headers","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/webhooksUpdateWebhookHeadersRequest"}}}},"x-fern-sdk-group-name":["pods","webhooks"],"x-fern-sdk-method-name":"update-headers"}},"/v0/threads":{"get":{"description":"Lists threads, most recent first. Pass `senders`, `recipients`, or\n`subject` to filter by substring. Filtered requests are served by\nsearch, which caps `limit` at 100. For relevance-ranked full-text\nsearch across senders, recipients, subject, and message body, use\n`Search Threads`.\n\n**CLI:**\n```bash\nagentmail threads list\n```","operationId":"threads_list","tags":["Threads"],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"labels","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Labels","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}},{"name":"ascending","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Ascending","nullable":true}},{"name":"include_spam","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeSpam","nullable":true}},{"name":"include_blocked","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeBlocked","nullable":true}},{"name":"include_unauthenticated","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeUnauthenticated","nullable":true}},{"name":"include_trash","in":"query","required":false,"schema":{"$ref":"#/components/schemas/IncludeTrash","nullable":true}},{"name":"senders","in":"query","description":"Filter to threads whose senders contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"recipients","in":"query","description":"Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}},{"name":"subject","in":"query","description":"Filter to threads whose subject contains this value (substring match). Repeatable; all values must match.","required":false,"schema":{"type":"array","items":{"type":"string"},"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListThreadsResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"List Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"list"}},"/v0/threads/search":{"get":{"description":"Full-text search across threads in the organization, ranked by\nrelevance. The query is matched against senders, recipients, and\nsubject (substring) and the message body (tokenized full text). Spam,\ntrash, blocked, and unauthenticated threads are always excluded.\n`limit` cannot exceed 100.","operationId":"threads_search","tags":["Threads"],"parameters":[{"name":"q","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Query"}},{"name":"limit","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Limit","nullable":true}},{"name":"page_token","in":"query","required":false,"schema":{"$ref":"#/components/schemas/PageToken","nullable":true}},{"name":"before","in":"query","required":false,"schema":{"$ref":"#/components/schemas/Before","nullable":true}},{"name":"after","in":"query","required":false,"schema":{"$ref":"#/components/schemas/After","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchThreadsResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Search Threads","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"search"}},"/v0/threads/{thread_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail threads get --thread-id \n```","operationId":"threads_get","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Thread"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"get"},"patch":{"description":"Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages.","operationId":"threads_update","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationErrorResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"422":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Update Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateThreadRequest"}}}},"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"update"},"delete":{"description":"Permanently deletes a thread and all of its messages.\n\n**CLI:**\n```bash\nagentmail threads delete --thread-id \n```","operationId":"threads_delete","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}}],"responses":{"204":{"description":""},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Delete Thread","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"delete"}},"/v0/threads/{thread_id}/attachments/{attachment_id}":{"get":{"description":"**CLI:**\n```bash\nagentmail threads get-attachment --thread-id --attachment-id \n```","operationId":"threads_getAttachment","tags":["Threads"],"parameters":[{"name":"thread_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/ThreadId"}},{"name":"attachment_id","in":"path","required":true,"schema":{"$ref":"#/components/schemas/AttachmentId"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AttachmentResponse"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}},"summary":"Get Attachment","servers":[{"url":"https://api.agentmail.to"},{"url":"https://x402.api.agentmail.to"},{"url":"https://mpp.api.agentmail.to"},{"url":"https://api.agentmail.eu"}],"security":[{"BearerAuth":[]},{"TokenAuth":[]}],"x-fern-sdk-group-name":["threads"],"x-fern-sdk-method-name":"get-attachment"}}},"components":{"schemas":{"Limit":{"title":"Limit","type":"integer","description":"Limit of number of items returned."},"Count":{"title":"Count","type":"integer","description":"Number of items returned."},"PageToken":{"title":"PageToken","type":"string","description":"Page token for pagination."},"Labels":{"title":"Labels","type":"array","items":{"type":"string"},"description":"Labels to filter by."},"Before":{"title":"Before","type":"string","format":"date-time","description":"Timestamp before which to filter by."},"After":{"title":"After","type":"string","format":"date-time","description":"Timestamp after which to filter by."},"Ascending":{"title":"Ascending","type":"boolean","description":"Sort in ascending temporal order."},"IncludeSpam":{"title":"IncludeSpam","type":"boolean","description":"Include spam in results."},"IncludeBlocked":{"title":"IncludeBlocked","type":"boolean","description":"Include blocked in results."},"IncludeUnauthenticated":{"title":"IncludeUnauthenticated","type":"boolean","description":"Include unauthenticated in results."},"IncludeTrash":{"title":"IncludeTrash","type":"boolean","description":"Include trash in results."},"OrganizationId":{"title":"OrganizationId","type":"string","description":"ID of organization."},"Query":{"title":"Query","type":"string","description":"Full-text search query. Matched against the sender, recipients, and\nsubject (substring) and the message body (tokenized full text)."},"ErrorName":{"title":"ErrorName","type":"string","description":"Name of error."},"ErrorMessage":{"title":"ErrorMessage","type":"string","description":"Error message."},"ErrorCode":{"title":"ErrorCode","type":"string","description":"Stable, machine-readable error code in snake_case (for example, not_found or missing_permission). Branch on this rather than the message text."},"ErrorFix":{"title":"ErrorFix","type":"string","description":"The concrete next action that resolves the error."},"ErrorDocs":{"title":"ErrorDocs","type":"string","description":"Link to the error reference entry for this code."},"ErrorResponse":{"title":"ErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"code":{"$ref":"#/components/schemas/ErrorCode","nullable":true},"message":{"$ref":"#/components/schemas/ErrorMessage"},"fix":{"$ref":"#/components/schemas/ErrorFix","nullable":true},"docs":{"$ref":"#/components/schemas/ErrorDocs","nullable":true}},"required":["name","message"]},"ValidationErrorResponse":{"title":"ValidationErrorResponse","type":"object","properties":{"name":{"$ref":"#/components/schemas/ErrorName"},"code":{"$ref":"#/components/schemas/ErrorCode","nullable":true},"message":{"$ref":"#/components/schemas/ErrorMessage","nullable":true},"errors":{"description":"Validation errors. Each entry has a path and a message identifying the invalid field."},"fix":{"$ref":"#/components/schemas/ErrorFix","nullable":true},"docs":{"$ref":"#/components/schemas/ErrorDocs","nullable":true}},"required":["name","errors"]},"inboxesInboxId":{"title":"inboxesInboxId","type":"string","description":"The ID of the inbox."},"inboxesEmail":{"title":"inboxesEmail","type":"string","description":"Email address of the inbox."},"inboxesDisplayName":{"title":"inboxesDisplayName","type":"string","description":"Display name: `Display Name `."},"inboxesClientId":{"title":"inboxesClientId","type":"string","description":"Client ID of inbox."},"inboxesMetadataValue":{"title":"inboxesMetadataValue","oneOf":[{"type":"string"},{"type":"number","format":"double"},{"type":"boolean"}],"description":"A metadata value. May be a string, number, or boolean."},"inboxesMetadata":{"title":"inboxesMetadata","type":"object","additionalProperties":{"$ref":"#/components/schemas/inboxesMetadataValue"},"description":"Custom key-value pairs attached to the inbox. Up to 256 keys. Keys and\nstring values are each limited to 256 characters. When updating metadata,\nsend a key with a null value to remove that key."},"inboxesUpdateMetadata":{"title":"inboxesUpdateMetadata","type":"object","additionalProperties":{"$ref":"#/components/schemas/inboxesMetadataValue","nullable":true},"description":"Custom key-value pairs to merge into the inbox's existing metadata. A\nvalue may be a string, number, boolean, or null. Setting a key to null\nremoves it. Up to 256 keys; keys and string values are each limited to\n256 characters."},"inboxesInbox":{"title":"inboxesInbox","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"email":{"$ref":"#/components/schemas/inboxesEmail"},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true},"metadata":{"$ref":"#/components/schemas/inboxesMetadata","nullable":true,"description":"Custom metadata attached to the inbox."},"updated_at":{"type":"string","format":"date-time","description":"Time at which inbox was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which inbox was created."}},"required":["pod_id","inbox_id","email","updated_at","created_at"]},"inboxesListInboxesResponse":{"title":"inboxesListInboxesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"inboxes":{"type":"array","items":{"$ref":"#/components/schemas/inboxesInbox"},"description":"Ordered by `created_at` descending."}},"required":["count","inboxes"]},"inboxesCreateInboxRequest":{"title":"inboxesCreateInboxRequest","type":"object","properties":{"username":{"type":"string","nullable":true,"description":"Username of address. Randomly generated if not specified."},"domain":{"type":"string","nullable":true,"description":"Domain of address. Must be a verified domain, or any subdomain of a\nverified domain that has subdomains enabled (e.g., `bot.example.com`).\nDefaults to `agentmail.to`."},"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"client_id":{"$ref":"#/components/schemas/inboxesClientId","nullable":true},"metadata":{"$ref":"#/components/schemas/inboxesMetadata","nullable":true,"description":"Custom metadata to attach to the inbox."}}},"inboxesUpdateInboxRequest":{"title":"inboxesUpdateInboxRequest","type":"object","properties":{"display_name":{"$ref":"#/components/schemas/inboxesDisplayName","nullable":true},"metadata":{"$ref":"#/components/schemas/inboxesUpdateMetadata","nullable":true,"description":"Metadata to merge into the inbox's existing metadata. Keys you include\nare added or overwritten; keys you omit are left unchanged. To remove a\nsingle key, send it with a null value. To clear all metadata, send\n`metadata` as null. Sending an empty object is rejected; use null to\nclear. Each update must include at least one of `display_name` or\n`metadata`."}}},"podsPodId":{"title":"podsPodId","type":"string","description":"ID of pod."},"podsName":{"title":"podsName","type":"string","description":"Name of pod."},"podsClientId":{"title":"podsClientId","type":"string","description":"Client ID of pod."},"podsPod":{"title":"podsPod","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId"},"name":{"$ref":"#/components/schemas/podsName"},"updated_at":{"type":"string","format":"date-time","description":"Time at which pod was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which pod was created."},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}},"required":["pod_id","name","updated_at","created_at"]},"podsListPodsResponse":{"title":"podsListPodsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"pods":{"type":"array","items":{"$ref":"#/components/schemas/podsPod"},"description":"Ordered by `created_at` descending."}},"required":["count","pods"]},"podsCreatePodRequest":{"title":"podsCreatePodRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/podsName","nullable":true},"client_id":{"$ref":"#/components/schemas/podsClientId","nullable":true}}},"webhooksWebhookId":{"title":"webhooksWebhookId","type":"string","description":"ID of webhook."},"webhooksClientId":{"title":"webhooksClientId","type":"string","description":"Client ID of webhook."},"webhooksUrl":{"title":"webhooksUrl","type":"string","description":"URL of webhook endpoint."},"webhooksWebhookHeaders":{"title":"webhooksWebhookHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Custom HTTP headers to include with every delivery to this webhook. Header values are write-only:\nAgentMail never returns them from webhook read endpoints. The map must contain at least one entry\nwhen provided, and every name and value must be a valid HTTP header."},"webhooksWebhookHeaderNamesResponse":{"title":"webhooksWebhookHeaderNamesResponse","type":"object","properties":{"header_names":{"type":"array","items":{"type":"string"},"description":"Names of the custom delivery headers configured for this webhook. Header values are never returned."}},"required":["header_names"]},"webhooksWebhook":{"title":"webhooksWebhook","type":"object","properties":{"webhook_id":{"$ref":"#/components/schemas/webhooksWebhookId"},"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"secret":{"type":"string","description":"Secret for webhook signature verification."},"enabled":{"type":"boolean","description":"Webhook is enabled."},"updated_at":{"type":"string","format":"date-time","description":"Time at which webhook was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which webhook was created."},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true}},"required":["webhook_id","url","secret","enabled","updated_at","created_at"]},"webhooksListWebhooksResponse":{"title":"webhooksListWebhooksResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"webhooks":{"type":"array","items":{"$ref":"#/components/schemas/webhooksWebhook"},"description":"Ordered by `created_at` descending."}},"required":["count","webhooks"]},"webhooksCreateWebhookEventTypes":{"title":"webhooksCreateWebhookEventTypes","$ref":"#/components/schemas/EventTypes","description":"Full list of event types this webhook should receive. At least one type is required. Send every type you\nwant in this array (not incremental). See [Webhooks overview](https://docs.agentmail.to/webhooks-overview)\nfor spam, blocked, and unauthenticated events and required permissions."},"webhooksUpdateWebhookEventTypes":{"title":"webhooksUpdateWebhookEventTypes","$ref":"#/components/schemas/EventTypes","description":"When you send a non-empty list, it replaces the webhook's subscribed event types in full (the same\n\"set the list\" behavior as create). It is not a merge or diff: include every event type you want after\nthe update. Sending a one-element array means the webhook will only receive that one type afterward.\nOmit this field or send an empty array to leave event types unchanged. Clearing all types with an empty\nlist is not supported. Subscribing to `message.received.spam`, `message.received.blocked`, or\n`message.received.unauthenticated` requires the matching label permission on the API key."},"webhooksCreateInboxWebhookRequest":{"title":"webhooksCreateInboxWebhookRequest","type":"object","description":"Create a webhook scoped to an inbox. The inbox comes from the path, so `inbox_ids` and `pod_ids`\nare not accepted.","properties":{"url":{"$ref":"#/components/schemas/webhooksUrl"},"event_types":{"$ref":"#/components/schemas/webhooksCreateWebhookEventTypes"},"client_id":{"$ref":"#/components/schemas/webhooksClientId","nullable":true},"headers":{"$ref":"#/components/schemas/webhooksWebhookHeaders","nullable":true}},"required":["url","event_types"]},"webhooksCreatePodWebhookRequest":{"title":"webhooksCreatePodWebhookRequest","type":"object","description":"Create a webhook scoped to a pod. The pod comes from the path, so `pod_ids` is not accepted.\nOptionally pass `inbox_ids` to narrow the webhook to specific inboxes within the pod; omit to\nreceive events for the whole pod.","properties":{"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true}},"allOf":[{"$ref":"#/components/schemas/webhooksCreateInboxWebhookRequest"}]},"webhooksCreateWebhookRequest":{"title":"webhooksCreateWebhookRequest","type":"object","properties":{"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"allOf":[{"$ref":"#/components/schemas/webhooksCreatePodWebhookRequest"}]},"webhooksUpdateInboxWebhookRequest":{"title":"webhooksUpdateInboxWebhookRequest","type":"object","description":"Update an inbox-scoped webhook. It is fixed to its inbox, so only `event_types` can change.","properties":{"event_types":{"$ref":"#/components/schemas/webhooksUpdateWebhookEventTypes","nullable":true}}},"webhooksUpdatePodWebhookRequest":{"title":"webhooksUpdatePodWebhookRequest","type":"object","description":"Update a pod-scoped webhook. You can adjust which inboxes within the pod it listens to and replace\nits `event_types`, but not the pod scope itself.","properties":{"add_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to subscribe to the webhook."},"remove_inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true,"description":"Inbox IDs to unsubscribe from the webhook."}},"allOf":[{"$ref":"#/components/schemas/webhooksUpdateInboxWebhookRequest"}]},"webhooksUpdateWebhookRequest":{"title":"webhooksUpdateWebhookRequest","type":"object","properties":{"add_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to subscribe to the webhook."},"remove_pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true,"description":"Pod IDs to unsubscribe from the webhook."}},"allOf":[{"$ref":"#/components/schemas/webhooksUpdatePodWebhookRequest"}]},"webhooksUpdateWebhookHeadersRequest":{"title":"webhooksUpdateWebhookHeadersRequest","type":"object","description":"Set, replace, or remove custom delivery headers. Provide at least one of `headers` or\n`remove_headers`. A header cannot be set and removed in the same request, regardless of casing.","properties":{"headers":{"$ref":"#/components/schemas/webhooksWebhookHeaders","nullable":true},"remove_headers":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Names of custom delivery headers to remove."}}},"AgentSignupRequest":{"title":"AgentSignupRequest","type":"object","description":"Request body to sign up an agent.","properties":{"human_email":{"type":"string","description":"Email address of the human who owns the agent. A 6-digit OTP will be sent to this address."},"username":{"type":"string","description":"Username for the auto-created inbox (e.g. \"my-agent\" creates my-agent@agentmail.to)."},"source":{"type":"string","nullable":true,"description":"The SDK, framework, or platform issuing this sign-up (e.g. `agentmail-python`, `agentmail-cli`, `agentmail-mcp`).\nIdentifies the caller — answers \"who is signing up\".\nMax 2048 characters."},"referrer":{"type":"string","nullable":true,"description":"The channel that drove this sign-up — where the agent or its developer discovered AgentMail\n(e.g. `agent.email`, a partner URL, a campaign tag). Answers \"where did this sign-up come from\".\nMax 2048 characters."}},"required":["human_email","username"]},"AgentSignupResponse":{"title":"AgentSignupResponse","type":"object","description":"Response after successful agent sign-up.","properties":{"organization_id":{"type":"string","description":"ID of the created organization."},"inbox_id":{"type":"string","description":"ID of the auto-created inbox."},"api_key":{"type":"string","description":"API key for authenticating subsequent requests. Store this securely, it cannot be retrieved again."}},"required":["organization_id","inbox_id","api_key"]},"AgentVerifyRequest":{"title":"AgentVerifyRequest","type":"object","description":"Request body to verify an agent with an OTP code.","properties":{"otp_code":{"type":"string","description":"6-digit verification code sent to the human's email address."}},"required":["otp_code"]},"AgentVerifyResponse":{"title":"AgentVerifyResponse","type":"object","description":"Response after successful agent verification.","properties":{"verified":{"type":"boolean","description":"Whether the organization was verified."}},"required":["verified"]},"ApiKeyId":{"title":"ApiKeyId","type":"string","description":"ID of api key."},"Prefix":{"title":"Prefix","type":"string","description":"Prefix of api key."},"Name":{"title":"Name","type":"string","description":"Name of api key."},"CreatedAt":{"title":"CreatedAt","type":"string","format":"date-time","description":"Time at which api key was created."},"PublicJwkCoordinate":{"title":"PublicJwkCoordinate","type":"string","pattern":"^[A-Za-z0-9_-]{43}$","minLength":43,"maxLength":43,"description":"A 32-byte P-256 coordinate encoded as unpadded base64url."},"PublicJwk":{"title":"PublicJwk","type":"object","description":"A public P-256 JWK. The object accepts exactly `kty`, `crv`, `x`, and `y`.\nPrivate key material such as `d`, embedded key IDs, and all other members\nare rejected. The server also rejects coordinates that are not a point on\nP-256.","properties":{"kty":{"type":"string","const":"EC"},"crv":{"type":"string","const":"P-256"},"x":{"$ref":"#/components/schemas/PublicJwkCoordinate"},"y":{"$ref":"#/components/schemas/PublicJwkCoordinate"}},"required":["kty","crv","x","y"]},"OrganizationPublicKeyScope":{"title":"OrganizationPublicKeyScope","type":"object","description":"Organization-wide authority.","properties":{}},"PodPublicKeyScope":{"title":"PodPublicKeyScope","type":"object","description":"Authority over one live pod and its inboxes.","properties":{"id":{"type":"string","format":"uuid","description":"ID of the pod."}},"required":["id"]},"InboxPublicKeyScope":{"title":"InboxPublicKeyScope","type":"object","description":"Authority over one live inbox incarnation.","properties":{"id":{"type":"string","format":"email","maxLength":254,"description":"ID of the inbox."}},"required":["id"]},"PublicKeyScope":{"title":"PublicKeyScope","oneOf":[{"type":"object","allOf":[{"type":"object","properties":{"type":{"type":"string","enum":["organization"]}}},{"$ref":"#/components/schemas/OrganizationPublicKeyScope"}],"required":["type"]},{"type":"object","allOf":[{"type":"object","properties":{"type":{"type":"string","enum":["pod"]}}},{"$ref":"#/components/schemas/PodPublicKeyScope"}],"required":["type"]},{"type":"object","allOf":[{"type":"object","properties":{"type":{"type":"string","enum":["inbox"]}}},{"$ref":"#/components/schemas/InboxPublicKeyScope"}],"required":["type"]}],"description":"The immutable scope in which a public-key credential can approve AgentID sign-in."},"PublicKeyMaterial":{"title":"PublicKeyMaterial","type":"object","description":"Registered public key material and its server-computed RFC 7638 thumbprint.","properties":{"jwk":{"$ref":"#/components/schemas/PublicJwk"},"fingerprint":{"type":"string","pattern":"^[A-Za-z0-9_-]{43}$","minLength":43,"maxLength":43,"description":"RFC 7638 SHA-256 JWK thumbprint encoded as unpadded base64url."}},"required":["jwk","fingerprint"]},"PublicKeyCredential":{"title":"PublicKeyCredential","type":"object","description":"An AgentID sign-in credential. `type` and `api_key_id` are server-owned;\nuse `api_key_id` as the JWS `kid`. This response never contains a bearer\nsecret or private key.","properties":{"api_key_id":{"type":"string","format":"uuid","description":"Server-generated credential ID. Store this value as the signing key's `kid`."},"type":{"type":"string","const":"public_key","description":"Server-owned credential discriminator. Callers cannot select or update it."},"name":{"$ref":"#/components/schemas/Name","description":"Human-readable credential name."},"public_key":{"$ref":"#/components/schemas/PublicKeyMaterial"},"scope":{"$ref":"#/components/schemas/PublicKeyScope"},"expires_at":{"type":"string","format":"date-time","nullable":true,"description":"Immutable absolute expiry. Omitted when the credential does not expire."},"revoked_at":{"type":"string","format":"date-time","nullable":true,"description":"Present when organization-wide revoke-all invalidated this credential generation."},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"required":["api_key_id","type","name","public_key","scope","created_at","updated_at"]},"CreatePublicKeyRequest":{"title":"CreatePublicKeyRequest","type":"object","description":"Register only a public P-256 JWK. Credential type, `api_key_id`, sign-in\neligibility, permissions, and generation are server-owned and are not\nrequest properties.","properties":{"public_key":{"$ref":"#/components/schemas/PublicJwk"},"name":{"type":"string","minLength":1,"maxLength":256,"nullable":true,"description":"Defaults to `AgentID key {first eight fingerprint characters}`."},"scope":{"$ref":"#/components/schemas/PublicKeyScope","nullable":true,"description":"Omit to inherit the registering bearer key's exact scope. An explicit\nscope must be the caller's scope or a live descendant."},"expires_at":{"type":"string","format":"date-time","nullable":true,"description":"Future absolute expiry. Omit to inherit the registering bearer key's\nexpiry. A child credential cannot outlive its creator."}},"required":["public_key"]},"UpdatePublicKeyNameRequest":{"title":"UpdatePublicKeyNameRequest","type":"object","description":"Rename a public-key credential. Key material, ID, type, scope, sign-in\neligibility, permissions, generation, and expiry are immutable.","properties":{"name":{"type":"string","minLength":1,"maxLength":256}},"required":["name"]},"ListPublicKeysResponse":{"title":"ListPublicKeysResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"public_keys":{"type":"array","items":{"$ref":"#/components/schemas/PublicKeyCredential"},"description":"Public-key credentials only, ordered by creation time descending by default."}},"required":["count","public_keys"]},"RevokeAllAgentIdSignInKeysResponse":{"title":"RevokeAllAgentIdSignInKeysResponse","type":"object","description":"Permanent idempotency receipt for an organization-wide AgentID sign-in key revocation.","properties":{"previous_generation":{"type":"integer","minimum":0},"current_generation":{"type":"integer","minimum":1},"revoked_at":{"type":"string","format":"date-time"}},"required":["previous_generation","current_generation","revoked_at"]},"ApiKeyPermissions":{"title":"ApiKeyPermissions","type":"object","description":"Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted.","properties":{"inbox_read":{"type":"boolean","nullable":true,"description":"Read inbox details."},"inbox_create":{"type":"boolean","nullable":true,"description":"Create new inboxes."},"inbox_update":{"type":"boolean","nullable":true,"description":"Update inbox settings."},"inbox_delete":{"type":"boolean","nullable":true,"description":"Delete inboxes."},"message_read":{"type":"boolean","nullable":true,"description":"Read messages. Also required to read threads."},"message_send":{"type":"boolean","nullable":true,"description":"Send messages."},"message_update":{"type":"boolean","nullable":true,"description":"Update message labels. Also required to update threads."},"message_delete":{"type":"boolean","nullable":true,"description":"Delete messages. Also required to delete threads."},"label_spam_read":{"type":"boolean","nullable":true,"description":"Access messages labeled spam."},"label_blocked_read":{"type":"boolean","nullable":true,"description":"Access messages labeled blocked."},"label_unauthenticated_read":{"type":"boolean","nullable":true,"description":"Access messages labeled unauthenticated."},"label_trash_read":{"type":"boolean","nullable":true,"description":"Access messages labeled trash."},"draft_read":{"type":"boolean","nullable":true,"description":"Read drafts."},"draft_create":{"type":"boolean","nullable":true,"description":"Create drafts."},"draft_update":{"type":"boolean","nullable":true,"description":"Update drafts."},"draft_delete":{"type":"boolean","nullable":true,"description":"Delete drafts."},"draft_send":{"type":"boolean","nullable":true,"description":"Send drafts."},"webhook_read":{"type":"boolean","nullable":true,"description":"Read webhook configurations."},"webhook_create":{"type":"boolean","nullable":true,"description":"Create webhooks."},"webhook_update":{"type":"boolean","nullable":true,"description":"Update webhooks."},"webhook_delete":{"type":"boolean","nullable":true,"description":"Delete webhooks."},"domain_read":{"type":"boolean","nullable":true,"description":"Read domain details."},"domain_create":{"type":"boolean","nullable":true,"description":"Create domains."},"domain_update":{"type":"boolean","nullable":true,"description":"Update domains."},"domain_delete":{"type":"boolean","nullable":true,"description":"Delete domains."},"list_entry_read":{"type":"boolean","nullable":true,"description":"Read list entries."},"list_entry_create":{"type":"boolean","nullable":true,"description":"Create list entries."},"list_entry_delete":{"type":"boolean","nullable":true,"description":"Delete list entries."},"metrics_read":{"type":"boolean","nullable":true,"description":"Read metrics."},"api_key_read":{"type":"boolean","nullable":true,"description":"Read API keys."},"api_key_create":{"type":"boolean","nullable":true,"description":"Create API keys."},"api_key_update":{"type":"boolean","nullable":true,"description":"Update API keys."},"api_key_delete":{"type":"boolean","nullable":true,"description":"Delete API keys."},"pod_read":{"type":"boolean","nullable":true,"description":"Read pods."},"pod_create":{"type":"boolean","nullable":true,"description":"Create pods."},"pod_delete":{"type":"boolean","nullable":true,"description":"Delete pods."}}},"ApiKey":{"title":"ApiKey","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"pod_id":{"type":"string","nullable":true,"description":"Pod ID the api key is scoped to. If set, the key can only access resources within this pod."},"inbox_id":{"type":"string","nullable":true,"description":"Inbox ID the api key is scoped to. If set, the key can only access resources within this inbox."},"used_at":{"type":"string","format":"date-time","nullable":true,"description":"Time at which api key was last used."},"permissions":{"$ref":"#/components/schemas/ApiKeyPermissions","nullable":true},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","prefix","name","created_at"]},"CreateApiKeyResponse":{"title":"CreateApiKeyResponse","type":"object","properties":{"api_key_id":{"$ref":"#/components/schemas/ApiKeyId"},"api_key":{"type":"string","description":"API key."},"prefix":{"$ref":"#/components/schemas/Prefix"},"name":{"$ref":"#/components/schemas/Name"},"pod_id":{"type":"string","nullable":true,"description":"Pod ID the api key is scoped to."},"inbox_id":{"type":"string","nullable":true,"description":"Inbox ID the api key is scoped to."},"permissions":{"$ref":"#/components/schemas/ApiKeyPermissions","nullable":true},"created_at":{"$ref":"#/components/schemas/CreatedAt"}},"required":["api_key_id","api_key","prefix","name","created_at"]},"ListApiKeysResponse":{"title":"ListApiKeysResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"api_keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"},"description":"Ordered by `created_at` descending."}},"required":["count","api_keys"]},"CreateApiKeyRequest":{"title":"CreateApiKeyRequest","type":"object","properties":{"name":{"$ref":"#/components/schemas/Name","nullable":true},"permissions":{"$ref":"#/components/schemas/ApiKeyPermissions","nullable":true}}},"AttachmentId":{"title":"AttachmentId","type":"string","description":"ID of attachment."},"AttachmentFilename":{"title":"AttachmentFilename","type":"string","description":"Filename of attachment."},"AttachmentSize":{"title":"AttachmentSize","type":"integer","description":"Size of attachment in bytes."},"AttachmentContentType":{"title":"AttachmentContentType","type":"string","description":"Content type of attachment."},"AttachmentContentDisposition":{"title":"AttachmentContentDisposition","type":"string","enum":["inline","attachment"],"description":"Content disposition of attachment."},"AttachmentContentId":{"title":"AttachmentContentId","type":"string","description":"Content ID of attachment."},"Attachment":{"title":"Attachment","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true}},"required":["attachment_id","size"]},"AttachmentResponse":{"title":"AttachmentResponse","type":"object","properties":{"attachment_id":{"$ref":"#/components/schemas/AttachmentId"},"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"size":{"$ref":"#/components/schemas/AttachmentSize"},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"download_url":{"type":"string","description":"URL to download the attachment."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["attachment_id","size","download_url","expires_at"]},"SendAttachment":{"title":"SendAttachment","type":"object","properties":{"filename":{"$ref":"#/components/schemas/AttachmentFilename","nullable":true},"content_type":{"$ref":"#/components/schemas/AttachmentContentType","nullable":true},"content_disposition":{"$ref":"#/components/schemas/AttachmentContentDisposition","nullable":true},"content_id":{"$ref":"#/components/schemas/AttachmentContentId","nullable":true},"content":{"type":"string","nullable":true,"description":"Base64 encoded content of attachment."},"url":{"type":"string","nullable":true,"description":"URL to the attachment."}}},"ScopeType":{"title":"ScopeType","type":"string","enum":["organization","pod","inbox"],"description":"The scope tier the authenticated credential is bound to."},"Identity":{"title":"Identity","type":"object","description":"Identity and scope of the authenticated credential.","properties":{"scope_type":{"$ref":"#/components/schemas/ScopeType"},"scope_id":{"type":"string","description":"ID of the most specific scope the credential is bound to.\nEquals inbox_id when scope_type is inbox, pod_id when pod, organization_id when organization."},"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"pod_id":{"type":"string","nullable":true,"description":"ID of the pod the credential is scoped to. Present when scope_type is pod or inbox."},"inbox_id":{"type":"string","nullable":true,"description":"ID of the inbox the credential is scoped to. Present when scope_type is inbox."},"api_key_id":{"type":"string","nullable":true,"description":"ID of the API key used to authenticate. Absent for JWT and proxy credentials."}},"required":["scope_type","scope_id","organization_id"]},"DomainId":{"title":"DomainId","type":"string","description":"The ID of the domain."},"DomainName":{"title":"DomainName","type":"string","description":"The name of the domain (e.g., `example.com`)."},"RecordType":{"title":"RecordType","type":"string","enum":["TXT","CNAME","MX"]},"VerificationStatus":{"title":"VerificationStatus","type":"string","enum":["NOT_STARTED","PENDING","INVALID","FAILED","VERIFYING","VERIFIED"]},"RecordStatus":{"title":"RecordStatus","type":"string","enum":["MISSING","INVALID","VALID"]},"VerificationRecord":{"title":"VerificationRecord","type":"object","properties":{"type":{"$ref":"#/components/schemas/RecordType","description":"The type of the DNS record."},"name":{"type":"string","description":"The name or host of the record."},"value":{"type":"string","description":"The value of the record."},"status":{"$ref":"#/components/schemas/RecordStatus","description":"The verification status of this specific record."},"priority":{"type":"integer","nullable":true,"description":"The priority of the MX record."}},"required":["type","name","value","status"]},"Status":{"title":"Status","$ref":"#/components/schemas/VerificationStatus","description":"The verification status of the domain."},"FeedbackEnabled":{"title":"FeedbackEnabled","type":"boolean","description":"Bounce and complaint notifications are sent to your inboxes."},"SubdomainsEnabled":{"title":"SubdomainsEnabled","type":"boolean","description":"Allow inboxes on any subdomain of this domain. Adds a required wildcard MX\nrecord (`*.`) to `records`."},"TrackingEnabled":{"title":"TrackingEnabled","type":"boolean","description":"Serve open tracking pixels from this domain. Adds a required `link.`\nCNAME record to `records`, which must be published and verified before\n`track_opens` can be used on a send."},"ClientId":{"title":"ClientId","type":"string","description":"Client ID of domain."},"Domain":{"title":"Domain","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"domain":{"$ref":"#/components/schemas/DomainName"},"status":{"$ref":"#/components/schemas/Status"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"subdomains_enabled":{"$ref":"#/components/schemas/SubdomainsEnabled"},"tracking_enabled":{"$ref":"#/components/schemas/TrackingEnabled"},"records":{"type":"array","items":{"$ref":"#/components/schemas/VerificationRecord"},"description":"A list of DNS records required to verify the domain. Includes a\nwildcard MX record (`*.`) when `subdomains_enabled` is true."},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","domain","status","feedback_enabled","subdomains_enabled","tracking_enabled","records","updated_at","created_at"]},"DomainItem":{"title":"DomainItem","type":"object","properties":{"pod_id":{"$ref":"#/components/schemas/podsPodId","nullable":true},"domain_id":{"$ref":"#/components/schemas/DomainId"},"domain":{"$ref":"#/components/schemas/DomainName"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled"},"subdomains_enabled":{"$ref":"#/components/schemas/SubdomainsEnabled"},"tracking_enabled":{"$ref":"#/components/schemas/TrackingEnabled"},"client_id":{"$ref":"#/components/schemas/ClientId","nullable":true},"updated_at":{"type":"string","format":"date-time","description":"Time at which the domain was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which the domain was created."}},"required":["domain_id","domain","feedback_enabled","subdomains_enabled","tracking_enabled","updated_at","created_at"]},"ListDomainsResponse":{"title":"ListDomainsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"domains":{"type":"array","items":{"$ref":"#/components/schemas/DomainItem"},"description":"Ordered by `created_at` descending."}},"required":["count","domains"]},"CreateDomainRequest":{"title":"CreateDomainRequest","type":"object","properties":{"domain":{"$ref":"#/components/schemas/DomainName"},"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled","nullable":true},"subdomains_enabled":{"$ref":"#/components/schemas/SubdomainsEnabled","nullable":true},"tracking_enabled":{"$ref":"#/components/schemas/TrackingEnabled","nullable":true}},"required":["domain"]},"UpdateDomainRequest":{"title":"UpdateDomainRequest","type":"object","description":"Provide at least one of `feedback_enabled`, `subdomains_enabled`, or\n`tracking_enabled`. Omitted\nfields are left unchanged; an empty body is rejected. Enabling\n`subdomains_enabled` on a verified domain returns it to `PENDING` until the\nnewly-required wildcard MX record (`*.`) is published and verified.","properties":{"feedback_enabled":{"$ref":"#/components/schemas/FeedbackEnabled","nullable":true},"subdomains_enabled":{"$ref":"#/components/schemas/SubdomainsEnabled","nullable":true},"tracking_enabled":{"$ref":"#/components/schemas/TrackingEnabled","nullable":true}}},"DraftId":{"title":"DraftId","type":"string","description":"ID of draft."},"DraftClientId":{"title":"DraftClientId","type":"string","description":"Client ID of draft."},"DraftLabels":{"title":"DraftLabels","type":"array","items":{"type":"string"},"description":"Labels of draft."},"DraftReplyTo":{"title":"DraftReplyTo","type":"array","items":{"type":"string"},"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"DraftTo":{"title":"DraftTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"DraftCc":{"title":"DraftCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"DraftBcc":{"title":"DraftBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"DraftSubject":{"title":"DraftSubject","type":"string","description":"Subject of draft."},"DraftPreview":{"title":"DraftPreview","type":"string","description":"Text preview of draft."},"DraftText":{"title":"DraftText","type":"string","description":"Plain text body of draft."},"DraftHtml":{"title":"DraftHtml","type":"string","description":"HTML body of draft."},"DraftAttachments":{"title":"DraftAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in draft."},"DraftInReplyTo":{"title":"DraftInReplyTo","type":"string","description":"ID of message being replied to."},"DraftForwardOf":{"title":"DraftForwardOf","type":"string","description":"ID of message being forwarded."},"DraftReplyAll":{"title":"DraftReplyAll","type":"boolean","description":"Reply to all recipients of the original message."},"DraftSendStatus":{"title":"DraftSendStatus","type":"string","enum":["scheduled","sending","failed"],"description":"Schedule send status of draft."},"DraftSendAt":{"title":"DraftSendAt","type":"string","format":"date-time","description":"Time at which to schedule send draft."},"DraftUpdatedAt":{"title":"DraftUpdatedAt","type":"string","format":"date-time","description":"Time at which draft was last updated."},"DraftItem":{"title":"DraftItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"labels":{"$ref":"#/components/schemas/DraftLabels"},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"forward_of":{"$ref":"#/components/schemas/DraftForwardOf","nullable":true},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"}},"required":["inbox_id","draft_id","labels","updated_at"]},"Draft":{"title":"Draft","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"draft_id":{"$ref":"#/components/schemas/DraftId"},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true},"labels":{"$ref":"#/components/schemas/DraftLabels"},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"preview":{"$ref":"#/components/schemas/DraftPreview","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/DraftAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"forward_of":{"$ref":"#/components/schemas/DraftForwardOf","nullable":true},"references":{"type":"array","items":{"type":"string"},"nullable":true,"description":"IDs of previous messages in thread."},"send_status":{"$ref":"#/components/schemas/DraftSendStatus","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"updated_at":{"$ref":"#/components/schemas/DraftUpdatedAt"},"created_at":{"type":"string","format":"date-time","description":"Time at which draft was created."}},"required":["inbox_id","draft_id","labels","updated_at","created_at"]},"ListDraftsResponse":{"title":"ListDraftsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"drafts":{"type":"array","items":{"$ref":"#/components/schemas/DraftItem"},"description":"Ordered by `updated_at` descending."}},"required":["count","drafts"]},"CreateDraftRequest":{"title":"CreateDraftRequest","type":"object","description":"Body for creating a draft. Supports plain, reply, reply-all, and forward\ndrafts:\n\n- **Plain draft:** supply `to`, `subject`, `text`, etc.\n- **Reply:** set `in_reply_to` to a message ID. Recipients, subject, and\n threading are derived from that message. Set `reply_all` to address the\n whole thread (you then cannot also pass `to`, `cc`, or `bcc`).\n- **Forward:** set `forward_of` to a message ID. The subject and threading\n are derived from the source message, whose body and attachments are\n merged in at send time.\n\n`in_reply_to` and `forward_of` are mutually exclusive, and reading the\nreferenced message requires `message_read` permission.","properties":{"labels":{"$ref":"#/components/schemas/DraftLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"attachments":{"type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"nullable":true,"description":"Attachments to include in draft."},"in_reply_to":{"$ref":"#/components/schemas/DraftInReplyTo","nullable":true},"forward_of":{"$ref":"#/components/schemas/DraftForwardOf","nullable":true},"reply_all":{"$ref":"#/components/schemas/DraftReplyAll","nullable":true},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true},"client_id":{"$ref":"#/components/schemas/DraftClientId","nullable":true}}},"UpdateDraftRequest":{"title":"UpdateDraftRequest","type":"object","description":"Edit fields on an existing draft. A draft's kind (plain, reply, or forward)\nis fixed at creation and cannot be changed here. Omitting a field leaves it\nunchanged; passing `null` (or `[]` for a recipient field) clears it. Pass\n`send_at` to schedule or reschedule the draft, or `null` to un-schedule it.","properties":{"reply_to":{"$ref":"#/components/schemas/DraftReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/DraftTo","nullable":true},"cc":{"$ref":"#/components/schemas/DraftCc","nullable":true},"bcc":{"$ref":"#/components/schemas/DraftBcc","nullable":true},"subject":{"$ref":"#/components/schemas/DraftSubject","nullable":true},"text":{"$ref":"#/components/schemas/DraftText","nullable":true},"html":{"$ref":"#/components/schemas/DraftHtml","nullable":true},"add_attachments":{"type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"nullable":true,"description":"Attachments to add to the draft."},"remove_attachments":{"type":"array","items":{"$ref":"#/components/schemas/AttachmentId"},"nullable":true,"description":"IDs of attachments to remove from the draft."},"add_labels":{"$ref":"#/components/schemas/DraftLabels","nullable":true,"description":"Label or labels to add to the draft."},"remove_labels":{"$ref":"#/components/schemas/DraftLabels","nullable":true,"description":"Label or labels to remove from the draft."},"send_at":{"$ref":"#/components/schemas/DraftSendAt","nullable":true}}},"EventType":{"title":"EventType","type":"string","enum":["message.received","message.received.spam","message.received.blocked","message.received.unauthenticated","message.sent","message.delivered","message.bounced","message.complained","message.rejected","message.opened","domain.verified"]},"EventTypes":{"title":"EventTypes","type":"array","items":{"$ref":"#/components/schemas/EventType"},"description":"Event types for which to send events."},"MessageReceivedEventType":{"title":"MessageReceivedEventType","type":"string","enum":["message.received","message.received.spam","message.received.blocked","message.received.unauthenticated"]},"PodIds":{"title":"PodIds","type":"array","items":{"type":"string"},"description":"Pods for which to send events. Maximum 10 per webhook."},"InboxIds":{"title":"InboxIds","type":"array","items":{"type":"string"},"description":"Inboxes for which to send events. Maximum 10 per webhook."},"EventId":{"title":"EventId","type":"string","description":"ID of event."},"Timestamp":{"title":"Timestamp","type":"string","format":"date-time","description":"Timestamp of event."},"Recipient":{"title":"Recipient","type":"object","properties":{"address":{"type":"string","description":"Recipient address."},"status":{"type":"string","description":"Recipient status."}},"required":["address","status"]},"Send":{"title":"Send","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Sent recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"],"x-fern-type-name":"SendEvent"},"Delivery":{"title":"Delivery","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"recipients":{"type":"array","items":{"type":"string"},"description":"Delivered recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","recipients"]},"Bounce":{"title":"Bounce","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Bounce type."},"sub_type":{"type":"string","description":"Bounce sub-type."},"recipients":{"type":"array","items":{"$ref":"#/components/schemas/Recipient"},"description":"Bounced recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Complaint":{"title":"Complaint","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"type":{"type":"string","description":"Complaint type."},"sub_type":{"type":"string","description":"Complaint sub-type."},"recipients":{"type":"array","items":{"type":"string"},"description":"Complained recipients."}},"required":["inbox_id","thread_id","message_id","timestamp","type","sub_type","recipients"]},"Reject":{"title":"Reject","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"},"reason":{"type":"string","description":"Reject reason."}},"required":["inbox_id","thread_id","message_id","timestamp","reason"]},"Open":{"title":"Open","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"timestamp":{"$ref":"#/components/schemas/Timestamp"}},"required":["inbox_id","thread_id","message_id","timestamp"]},"MessageReceivedEvent":{"title":"MessageReceivedEvent","type":"object","description":"A message was received. Spam, blocked, and unauthenticated received-message events use the same payload shape with different `event_type` values.","properties":{"type":{"type":"string","const":"event"},"event_type":{"$ref":"#/components/schemas/MessageReceivedEventType"},"event_id":{"$ref":"#/components/schemas/EventId"},"message":{"$ref":"#/components/schemas/Message"},"thread":{"$ref":"#/components/schemas/ThreadItem"}},"required":["type","event_type","event_id","message","thread"]},"MessageSentEvent":{"title":"MessageSentEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.sent"},"event_id":{"$ref":"#/components/schemas/EventId"},"send":{"$ref":"#/components/schemas/Send"}},"required":["type","event_type","event_id","send"]},"MessageDeliveredEvent":{"title":"MessageDeliveredEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.delivered"},"event_id":{"$ref":"#/components/schemas/EventId"},"delivery":{"$ref":"#/components/schemas/Delivery"}},"required":["type","event_type","event_id","delivery"]},"MessageBouncedEvent":{"title":"MessageBouncedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.bounced"},"event_id":{"$ref":"#/components/schemas/EventId"},"bounce":{"$ref":"#/components/schemas/Bounce"}},"required":["type","event_type","event_id","bounce"]},"MessageComplainedEvent":{"title":"MessageComplainedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.complained"},"event_id":{"$ref":"#/components/schemas/EventId"},"complaint":{"$ref":"#/components/schemas/Complaint"}},"required":["type","event_type","event_id","complaint"]},"MessageRejectedEvent":{"title":"MessageRejectedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.rejected"},"event_id":{"$ref":"#/components/schemas/EventId"},"reject":{"$ref":"#/components/schemas/Reject"}},"required":["type","event_type","event_id","reject"]},"MessageOpenedEvent":{"title":"MessageOpenedEvent","type":"object","description":"A tracked message was opened for the first time. Sent once per message: repeat opens do not\nresend it.","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"message.opened"},"event_id":{"$ref":"#/components/schemas/EventId"},"open":{"$ref":"#/components/schemas/Open"}},"required":["type","event_type","event_id","open"]},"DomainVerifiedEvent":{"title":"DomainVerifiedEvent","type":"object","properties":{"type":{"type":"string","const":"event"},"event_type":{"type":"string","const":"domain.verified"},"event_id":{"$ref":"#/components/schemas/EventId"},"domain":{"$ref":"#/components/schemas/Domain"}},"required":["type","event_type","event_id","domain"]},"InboxEventId":{"title":"InboxEventId","type":"string","description":"ID of event."},"InboxEventType":{"title":"InboxEventType","type":"string","enum":["label.added","label.removed"],"description":"Type of inbox event. Wire format is dot.case to match the\nconvention used by webhook events (`message.received`,\n`domain.verified`, etc. in events.yml). Pre-2026-04 these were\n`label_added`/`label_removed` (snake_case). The Fern enum's `name`\nfield stays uppercase-snake (Fern convention); only the wire\n`value` changed."},"InboxEvent":{"title":"InboxEvent","type":"object","properties":{"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"pod_id":{"type":"string","description":"ID of pod."},"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"event_id":{"$ref":"#/components/schemas/InboxEventId"},"event_type":{"$ref":"#/components/schemas/InboxEventType"},"message_id":{"type":"string","description":"ID of message."},"label":{"type":"string","description":"Label added or removed."},"event_at":{"type":"string","format":"date-time","description":"Time at which the event occurred."},"created_at":{"type":"string","format":"date-time","description":"Time at which the event was recorded."}},"required":["organization_id","pod_id","inbox_id","event_id","event_type","message_id","label","event_at","created_at"]},"ListInboxEventsResponse":{"title":"ListInboxEventsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"events":{"type":"array","items":{"$ref":"#/components/schemas/InboxEvent"},"description":"Ordered by `event_id` descending."}},"required":["count","events"]},"Direction":{"title":"Direction","type":"string","enum":["send","receive","reply"],"description":"Direction of list entry."},"ListType":{"title":"ListType","type":"string","enum":["allow","block"],"description":"Type of list entry."},"EntryType":{"title":"EntryType","type":"string","enum":["email","domain"],"description":"Whether the entry is an email address or domain."},"ListEntryBase":{"title":"ListEntryBase","type":"object","properties":{"entry":{"type":"string","description":"Email address or domain of list entry."},"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"reason":{"type":"string","nullable":true,"description":"Reason for adding the entry."},"direction":{"$ref":"#/components/schemas/Direction"},"list_type":{"$ref":"#/components/schemas/ListType"},"entry_type":{"$ref":"#/components/schemas/EntryType"},"created_at":{"type":"string","format":"date-time","description":"Time at which entry was created."},"read_only":{"type":"boolean","nullable":true,"description":"Whether the entry is read-only and cannot be deleted via the API."}},"required":["entry","organization_id","direction","list_type","entry_type","created_at"]},"ListEntry":{"title":"ListEntry","type":"object","properties":{},"allOf":[{"$ref":"#/components/schemas/ListEntryBase"}]},"PodListEntry":{"title":"PodListEntry","type":"object","properties":{"pod_id":{"type":"string","description":"ID of pod."},"inbox_id":{"type":"string","nullable":true,"description":"ID of inbox, if entry is inbox-scoped."}},"required":["pod_id"],"allOf":[{"$ref":"#/components/schemas/ListEntryBase"}]},"PodListListEntriesResponse":{"title":"PodListListEntriesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"entries":{"type":"array","items":{"$ref":"#/components/schemas/PodListEntry"},"description":"Ordered by entry ascending."}},"required":["count","entries"]},"ListListEntriesResponse":{"title":"ListListEntriesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"entries":{"type":"array","items":{"$ref":"#/components/schemas/ListEntry"},"description":"Ordered by entry ascending."}},"required":["count","entries"]},"CreateListEntryRequest":{"title":"CreateListEntryRequest","type":"object","properties":{"entry":{"type":"string","description":"Email address or domain to add."},"reason":{"type":"string","nullable":true,"description":"Reason for adding the entry."}},"required":["entry"]},"MessageId":{"title":"MessageId","type":"string","description":"ID of message."},"MessageLabels":{"title":"MessageLabels","type":"array","items":{"type":"string"},"description":"Labels of message."},"MessageTimestamp":{"title":"MessageTimestamp","type":"string","format":"date-time","description":"Time at which message was sent or drafted."},"MessageFrom":{"title":"MessageFrom","type":"string","description":"Address of sender. In format `username@domain.com` or `Display Name `."},"MessageReplyTo":{"title":"MessageReplyTo","type":"array","items":{"type":"string"},"description":"Addresses of reply-to recipients. In format `username@domain.com` or `Display Name `."},"MessageTo":{"title":"MessageTo","type":"array","items":{"type":"string"},"description":"Addresses of recipients. In format `username@domain.com` or `Display Name `."},"MessageCc":{"title":"MessageCc","type":"array","items":{"type":"string"},"description":"Addresses of CC recipients. In format `username@domain.com` or `Display Name `."},"MessageBcc":{"title":"MessageBcc","type":"array","items":{"type":"string"},"description":"Addresses of BCC recipients. In format `username@domain.com` or `Display Name `."},"MessageSubject":{"title":"MessageSubject","type":"string","description":"Subject of message."},"MessagePreview":{"title":"MessagePreview","type":"string","description":"Text preview of message."},"MessageText":{"title":"MessageText","type":"string","description":"Plain text body of message."},"MessageHtml":{"title":"MessageHtml","type":"string","description":"HTML body of message."},"MessageAttachments":{"title":"MessageAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in message."},"MessageInReplyTo":{"title":"MessageInReplyTo","type":"string","description":"ID of message being replied to."},"MessageReferences":{"title":"MessageReferences","type":"array","items":{"type":"string"},"description":"IDs of previous messages in thread."},"MessageHeaders":{"title":"MessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers in message."},"MessageSize":{"title":"MessageSize","type":"integer","description":"Size of message in bytes."},"MessageUpdatedAt":{"title":"MessageUpdatedAt","type":"string","format":"date-time","description":"Time at which message was last updated."},"MessageCreatedAt":{"title":"MessageCreatedAt","type":"string","format":"date-time","description":"Time at which message was created."},"MessageItem":{"title":"MessageItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"Message":{"title":"Message","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"},"timestamp":{"$ref":"#/components/schemas/MessageTimestamp"},"from":{"$ref":"#/components/schemas/MessageFrom"},"reply_to":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Reply-to addresses. In format `username@domain.com` or `Display Name `."},"to":{"$ref":"#/components/schemas/MessageTo"},"cc":{"$ref":"#/components/schemas/MessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/MessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"preview":{"$ref":"#/components/schemas/MessagePreview","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"extracted_text":{"type":"string","nullable":true,"description":"Extracted new text content."},"extracted_html":{"type":"string","nullable":true,"description":"Extracted new HTML content."},"attachments":{"$ref":"#/components/schemas/MessageAttachments","nullable":true},"in_reply_to":{"$ref":"#/components/schemas/MessageInReplyTo","nullable":true},"references":{"$ref":"#/components/schemas/MessageReferences","nullable":true},"headers":{"$ref":"#/components/schemas/MessageHeaders","nullable":true},"size":{"$ref":"#/components/schemas/MessageSize"},"updated_at":{"$ref":"#/components/schemas/MessageUpdatedAt"},"created_at":{"$ref":"#/components/schemas/MessageCreatedAt"}},"required":["inbox_id","thread_id","message_id","labels","timestamp","from","to","size","updated_at","created_at"]},"ListMessagesResponse":{"title":"ListMessagesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MessageItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","messages"]},"SearchMessageHighlights":{"title":"SearchMessageHighlights","type":"object","description":"Matched fragments per field on a message search result, with matched terms\nwrapped in `**`. A field key is present only when the query matched that\nfield, so the present keys also tell you which fields produced the hit.","properties":{"from":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from the sender address."},"recipients":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from the recipient addresses (to, cc, or bcc)."},"subject":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from the subject."},"text":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from the message body."}}},"SearchMessageItem":{"title":"SearchMessageItem","type":"object","properties":{"highlights":{"$ref":"#/components/schemas/SearchMessageHighlights","nullable":true,"description":"Matched fragments per field. Present only when the query matched an indexed field."}},"allOf":[{"$ref":"#/components/schemas/MessageItem"}]},"SearchMessagesResponse":{"title":"SearchMessagesResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"messages":{"type":"array","items":{"$ref":"#/components/schemas/SearchMessageItem"},"description":"Ordered by relevance, best match first."}},"required":["count","messages"]},"BatchGetMessagesMessageIds":{"title":"BatchGetMessagesMessageIds","type":"array","items":{"$ref":"#/components/schemas/MessageId"},"description":"IDs of messages to fetch. Maximum 500 ids per request. Duplicates are\nrejected with a validation error. IDs not found in the inbox (including\ncross-inbox or permission-restricted) are silently omitted from the\nresponse; callers detect misses by comparing `count` against `limit`."},"BatchGetMessagesRequest":{"title":"BatchGetMessagesRequest","type":"object","properties":{"message_ids":{"$ref":"#/components/schemas/BatchGetMessagesMessageIds"}},"required":["message_ids"]},"BatchGetMessagesResponse":{"title":"BatchGetMessagesResponse","type":"object","properties":{"limit":{"$ref":"#/components/schemas/Limit"},"count":{"$ref":"#/components/schemas/Count"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"},"description":"Found messages. Order matches `message_ids` in the request. Body\nfields (`text`, `html`, `extracted_text`, `extracted_html`) are\nnever populated; use the single-message endpoint to retrieve bodies."}},"required":["limit","count","messages"]},"BatchUpdateMessagesMessageIds":{"title":"BatchUpdateMessagesMessageIds","type":"array","items":{"$ref":"#/components/schemas/MessageId"},"description":"IDs of messages to update. Maximum 50 ids per request. Duplicates are\nrejected with a validation error. IDs not found in the inbox (including\ncross-inbox or permission-restricted) are silently excluded from the\nupdate; callers detect exclusions by comparing `count` against `limit`."},"BatchUpdateMessagesRequest":{"title":"BatchUpdateMessagesRequest","type":"object","properties":{"message_ids":{"$ref":"#/components/schemas/BatchUpdateMessagesMessageIds"},"add_labels":{"$ref":"#/components/schemas/UpdateMessageLabels","nullable":true,"description":"Label or labels to add to every message."},"remove_labels":{"$ref":"#/components/schemas/UpdateMessageLabels","nullable":true,"description":"Label or labels to remove from every message."}},"required":["message_ids"]},"BatchUpdateMessagesResponse":{"title":"BatchUpdateMessagesResponse","type":"object","properties":{"limit":{"$ref":"#/components/schemas/Limit"},"count":{"$ref":"#/components/schemas/Count"},"updates":{"type":"array","items":{"$ref":"#/components/schemas/UpdateMessageResponse"},"description":"Updated messages with their new labels. Order matches `message_ids`\nin the request. Excluded ids are omitted, so `count` may be less than\n`limit`."}},"required":["limit","count","updates"]},"RawMessageResponse":{"title":"RawMessageResponse","type":"object","description":"S3 presigned URL to download the raw .eml file.","properties":{"message_id":{"$ref":"#/components/schemas/MessageId","description":"ID of the message."},"size":{"$ref":"#/components/schemas/MessageSize","description":"Size of the raw message in bytes."},"download_url":{"type":"string","description":"S3 presigned URL to download the raw message. Expires at expires_at."},"expires_at":{"type":"string","format":"date-time","description":"Time at which the download URL expires."}},"required":["message_id","size","download_url","expires_at"]},"Addresses":{"title":"Addresses","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]},"SendMessageReplyTo":{"title":"SendMessageReplyTo","$ref":"#/components/schemas/Addresses","description":"Reply-to address or addresses."},"SendMessageTo":{"title":"SendMessageTo","$ref":"#/components/schemas/Addresses","description":"Recipient address or addresses."},"SendMessageCc":{"title":"SendMessageCc","$ref":"#/components/schemas/Addresses","description":"CC recipient address or addresses."},"SendMessageBcc":{"title":"SendMessageBcc","$ref":"#/components/schemas/Addresses","description":"BCC recipient address or addresses."},"SendMessageAttachments":{"title":"SendMessageAttachments","type":"array","items":{"$ref":"#/components/schemas/SendAttachment"},"description":"Attachments to include in message."},"SendMessageHeaders":{"title":"SendMessageHeaders","type":"object","additionalProperties":{"type":"string"},"description":"Headers to include in message."},"TrackOpens":{"title":"TrackOpens","type":"boolean","description":"Track when this message is first opened. Requires a custom domain with tracking enabled and\nan HTML body. Opens surface as the `opened` label on the message and as a `message.opened`\nevent. One pixel is injected per message, not per recipient, so a message with several\nrecipients fires once when any of them opens it, and the event does not identify which one."},"SendMessageRequest":{"title":"SendMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"subject":{"$ref":"#/components/schemas/MessageSubject","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true},"track_opens":{"$ref":"#/components/schemas/TrackOpens","nullable":true}}},"SendMessageResponse":{"title":"SendMessageResponse","type":"object","properties":{"message_id":{"$ref":"#/components/schemas/MessageId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"}},"required":["message_id","thread_id"]},"UpdateMessageResponse":{"title":"UpdateMessageResponse","type":"object","properties":{"message_id":{"$ref":"#/components/schemas/MessageId"},"labels":{"$ref":"#/components/schemas/MessageLabels"}},"required":["message_id","labels"]},"ReplyAll":{"title":"ReplyAll","type":"boolean","description":"Reply to all recipients of the original message."},"ReplyToMessageRequest":{"title":"ReplyToMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"to":{"$ref":"#/components/schemas/SendMessageTo","nullable":true},"cc":{"$ref":"#/components/schemas/SendMessageCc","nullable":true},"bcc":{"$ref":"#/components/schemas/SendMessageBcc","nullable":true},"reply_all":{"$ref":"#/components/schemas/ReplyAll","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true},"track_opens":{"$ref":"#/components/schemas/TrackOpens","nullable":true}}},"ReplyAllMessageRequest":{"title":"ReplyAllMessageRequest","type":"object","properties":{"labels":{"$ref":"#/components/schemas/MessageLabels","nullable":true},"reply_to":{"$ref":"#/components/schemas/SendMessageReplyTo","nullable":true},"text":{"$ref":"#/components/schemas/MessageText","nullable":true},"html":{"$ref":"#/components/schemas/MessageHtml","nullable":true},"attachments":{"$ref":"#/components/schemas/SendMessageAttachments","nullable":true},"headers":{"$ref":"#/components/schemas/SendMessageHeaders","nullable":true},"track_opens":{"$ref":"#/components/schemas/TrackOpens","nullable":true}}},"UpdateMessageLabels":{"title":"UpdateMessageLabels","oneOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"Label or list of labels."},"UpdateMessageRequest":{"title":"UpdateMessageRequest","type":"object","properties":{"add_labels":{"$ref":"#/components/schemas/UpdateMessageLabels","nullable":true,"description":"Label or labels to add to message."},"remove_labels":{"$ref":"#/components/schemas/UpdateMessageLabels","nullable":true,"description":"Label or labels to remove from message."}}},"MetricEventType":{"title":"MetricEventType","type":"string","enum":["message.received","message.received.spam","message.received.blocked","message.received.unauthenticated","message.sent","message.delivered","message.bounced","message.complained","message.rejected","domain.verified"],"description":"Type of metric event."},"MetricEventTypes":{"title":"MetricEventTypes","type":"array","items":{"$ref":"#/components/schemas/MetricEventType"},"description":"List of metric event types to query."},"Start":{"title":"Start","type":"string","format":"date-time","description":"Start timestamp for the query."},"End":{"title":"End","type":"string","format":"date-time","description":"End timestamp for the query."},"Period":{"title":"Period","type":"integer","description":"Size of each time bucket as a whole number of seconds, between 1 and 86400."},"MetricLimit":{"title":"MetricLimit","type":"integer","description":"Limit on number of buckets to return."},"Descending":{"title":"Descending","type":"boolean","description":"Sort in descending order."},"MetricBucket":{"title":"MetricBucket","type":"object","properties":{"timestamp":{"type":"string","format":"date-time","description":"Timestamp of the bucket."},"count":{"type":"integer","description":"Count of events in the bucket."}},"required":["timestamp","count"]},"QueryMetricsResponse":{"title":"QueryMetricsResponse","type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/MetricBucket"}},"description":"Metrics grouped by event type."},"UsageType":{"title":"UsageType","type":"string","enum":["storage_bytes","message_count","thread_count","inbox_count","pod_count","domain_count"],"description":"Type of usage metric. Inbox-scoped queries carry `storage_bytes`,\n`message_count`, and `thread_count`; pod-scoped queries add `inbox_count`\nand `domain_count`; organization-scoped queries add `pod_count`."},"UsageTypes":{"title":"UsageTypes","type":"array","items":{"$ref":"#/components/schemas/UsageType"},"description":"List of usage metric types to query. Omit to query every type valid for the scope."},"UsagePoint":{"title":"UsagePoint","type":"object","properties":{"timestamp":{"type":"string","format":"date-time","description":"Timestamp of the point."},"value":{"type":"integer","format":"int64","description":"Cumulative value of the usage metric at the timestamp."}},"required":["timestamp","value"]},"QueryUsageResponse":{"title":"QueryUsageResponse","type":"object","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/UsagePoint"}},"description":"Cumulative usage series grouped by usage type."},"Organization":{"title":"Organization","type":"object","description":"Organization details with usage limits and counts.","properties":{"organization_id":{"$ref":"#/components/schemas/OrganizationId"},"inbox_count":{"type":"integer","description":"Current number of inboxes."},"domain_count":{"type":"integer","description":"Current number of domains."},"inbox_limit":{"type":"integer","nullable":true,"description":"Maximum number of inboxes allowed."},"domain_limit":{"type":"integer","nullable":true,"description":"Maximum number of domains allowed."},"billing_id":{"type":"string","nullable":true,"description":"Provider-agnostic billing customer ID."},"billing_type":{"type":"string","nullable":true,"description":"Billing provider type (e.g. \"stripe\")."},"billing_subscription_id":{"type":"string","nullable":true,"description":"Active billing subscription ID."},"authentication_id":{"type":"string","nullable":true,"description":"Provider-agnostic authentication ID."},"authentication_type":{"type":"string","nullable":true,"description":"Authentication provider type."},"updated_at":{"type":"string","format":"date-time","description":"Time at which organization was last updated."},"created_at":{"type":"string","format":"date-time","description":"Time at which organization was created."}},"required":["organization_id","inbox_count","domain_count","updated_at","created_at"]},"ThreadId":{"title":"ThreadId","type":"string","description":"ID of thread."},"ThreadLabels":{"title":"ThreadLabels","type":"array","items":{"type":"string"},"description":"Labels of thread."},"ThreadTimestamp":{"title":"ThreadTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent or received message."},"ThreadReceivedTimestamp":{"title":"ThreadReceivedTimestamp","type":"string","format":"date-time","description":"Timestamp of last received message."},"ThreadSentTimestamp":{"title":"ThreadSentTimestamp","type":"string","format":"date-time","description":"Timestamp of last sent message."},"ThreadSenders":{"title":"ThreadSenders","type":"array","items":{"type":"string"},"description":"Senders in thread. In format `username@domain.com` or `Display Name `."},"ThreadRecipients":{"title":"ThreadRecipients","type":"array","items":{"type":"string"},"description":"Recipients in thread. In format `username@domain.com` or `Display Name `."},"ThreadSubject":{"title":"ThreadSubject","type":"string","description":"Subject of thread."},"ThreadPreview":{"title":"ThreadPreview","type":"string","description":"Text preview of last message in thread."},"ThreadAttachments":{"title":"ThreadAttachments","type":"array","items":{"$ref":"#/components/schemas/Attachment"},"description":"Attachments in thread."},"ThreadLastMessageId":{"title":"ThreadLastMessageId","type":"string","description":"ID of last message in thread."},"ThreadMessageCount":{"title":"ThreadMessageCount","type":"integer","description":"Number of messages in thread."},"ThreadSize":{"title":"ThreadSize","type":"integer","description":"Size of thread in bytes."},"ThreadUpdatedAt":{"title":"ThreadUpdatedAt","type":"string","format":"date-time","description":"Time at which thread was last updated."},"ThreadCreatedAt":{"title":"ThreadCreatedAt","type":"string","format":"date-time","description":"Time at which thread was created."},"ThreadItem":{"title":"ThreadItem","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at"]},"Thread":{"title":"Thread","type":"object","properties":{"inbox_id":{"$ref":"#/components/schemas/inboxesInboxId"},"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"},"timestamp":{"$ref":"#/components/schemas/ThreadTimestamp"},"received_timestamp":{"$ref":"#/components/schemas/ThreadReceivedTimestamp","nullable":true},"sent_timestamp":{"$ref":"#/components/schemas/ThreadSentTimestamp","nullable":true},"senders":{"$ref":"#/components/schemas/ThreadSenders"},"recipients":{"$ref":"#/components/schemas/ThreadRecipients"},"subject":{"$ref":"#/components/schemas/ThreadSubject","nullable":true},"preview":{"$ref":"#/components/schemas/ThreadPreview","nullable":true},"attachments":{"$ref":"#/components/schemas/ThreadAttachments","nullable":true},"last_message_id":{"$ref":"#/components/schemas/ThreadLastMessageId"},"message_count":{"$ref":"#/components/schemas/ThreadMessageCount"},"size":{"$ref":"#/components/schemas/ThreadSize"},"updated_at":{"$ref":"#/components/schemas/ThreadUpdatedAt"},"created_at":{"$ref":"#/components/schemas/ThreadCreatedAt"},"messages":{"type":"array","items":{"$ref":"#/components/schemas/Message"},"description":"Messages in thread. Ordered by `timestamp` ascending."}},"required":["inbox_id","thread_id","labels","timestamp","senders","recipients","last_message_id","message_count","size","updated_at","created_at","messages"]},"UpdateThreadRequest":{"title":"UpdateThreadRequest","type":"object","properties":{"add_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to add to thread. Cannot be system labels."},"remove_labels":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Labels to remove from thread. Cannot be system labels. Takes priority over `add_labels` (in the event of duplicate labels passed in)."}}},"UpdateThreadResponse":{"title":"UpdateThreadResponse","type":"object","properties":{"thread_id":{"$ref":"#/components/schemas/ThreadId"},"labels":{"$ref":"#/components/schemas/ThreadLabels"}},"required":["thread_id","labels"]},"ListThreadsResponse":{"title":"ListThreadsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"threads":{"type":"array","items":{"$ref":"#/components/schemas/ThreadItem"},"description":"Ordered by `timestamp` descending."}},"required":["count","threads"]},"SearchThreadHighlights":{"title":"SearchThreadHighlights","type":"object","description":"Matched fragments per field on a thread search result, with matched terms\nwrapped in `**`. A field key is present only when the query matched that\nfield, so the present keys also tell you which fields produced the hit.","properties":{"from":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from a sender address in the thread."},"recipients":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from a recipient address in the thread (to, cc, or bcc)."},"subject":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from the subject."},"text":{"type":"array","items":{"type":"string"},"nullable":true,"description":"Matched fragments from a message body in the thread."}}},"SearchThreadItem":{"title":"SearchThreadItem","type":"object","properties":{"highlights":{"$ref":"#/components/schemas/SearchThreadHighlights","nullable":true,"description":"Matched fragments per field. Present only when the query matched an indexed field."}},"allOf":[{"$ref":"#/components/schemas/ThreadItem"}]},"SearchThreadsResponse":{"title":"SearchThreadsResponse","type":"object","properties":{"count":{"$ref":"#/components/schemas/Count"},"limit":{"$ref":"#/components/schemas/Limit","nullable":true},"next_page_token":{"$ref":"#/components/schemas/PageToken","nullable":true},"threads":{"type":"array","items":{"$ref":"#/components/schemas/SearchThreadItem"},"description":"Ordered by relevance, best match first."}},"required":["count","threads"]},"webhooksSvixId":{"title":"webhooksSvixId","type":"string","description":"ID of webhook message."},"webhooksSvixTimestamp":{"title":"webhooksSvixTimestamp","type":"string","format":"date-time","description":"Timestamp of webhook message."},"webhooksSvixSignature":{"title":"webhooksSvixSignature","type":"string","description":"Signature of webhook message."},"Subscribe":{"title":"Subscribe","type":"object","properties":{"type":{"type":"string","const":"subscribe"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]},"Subscribed":{"title":"Subscribed","type":"object","properties":{"type":{"type":"string","const":"subscribed"},"event_types":{"$ref":"#/components/schemas/EventTypes","nullable":true},"inbox_ids":{"$ref":"#/components/schemas/InboxIds","nullable":true},"pod_ids":{"$ref":"#/components/schemas/PodIds","nullable":true}},"required":["type"]},"Error":{"title":"Error","type":"object","properties":{"type":{"type":"string","const":"error"},"name":{"$ref":"#/components/schemas/ErrorName"},"message":{"$ref":"#/components/schemas/ErrorMessage"}},"required":["type","name","message"]}},"securitySchemes":{"BearerAuth":{"type":"http","scheme":"bearer"},"TokenAuth":{"type":"http","scheme":"bearer"}}}} \ No newline at end of file diff --git a/cli/agentmail/sdk.rs b/cli/agentmail/sdk.rs new file mode 100644 index 0000000..8ded18f --- /dev/null +++ b/cli/agentmail/sdk.rs @@ -0,0 +1,144 @@ +//! Generated SDK client adapter — bridges AppContext to the co-generated SDK. +//! +//! Auto-generated by @fern-api/cli-generator. Do not edit by hand. + +#![allow(dead_code)] + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use fern_cli_sdk::error::CliError; +use fern_cli_sdk::openapi::AppContext; +use fern_cli_sdk::sdk_executor::{CliExecutor, SdkError, SdkRequestExecutor}; + +// --------------------------------------------------------------------------- +// Executor adapter: CliExecutor → SDK RequestExecutor +// --------------------------------------------------------------------------- + +struct CliExecutorAdapter(Arc); + +impl agentmail_sdk::RequestExecutor for CliExecutorAdapter { + fn execute( + &self, + request: reqwest::Request, + ) -> Pin< + Box< + dyn Future>> + + Send + + '_, + >, + > { + Box::pin(async move { + SdkRequestExecutor::execute(&*self.0, request) + .await + .map_err(|e| Box::new(e) as Box) + }) + } +} + +// --------------------------------------------------------------------------- +// client — construct a fully-wired SDK root client +// --------------------------------------------------------------------------- + +/// Build the SDK root client from the CLI's runtime context. +/// +/// The returned client routes all HTTP through the CLI's executor, so +/// it inherits auth, retries, TLS, and global headers automatically. +pub fn client(ctx: &AppContext) -> agentmail_sdk::api::ApiClient { + let executor = ctx.build_sdk_executor(); + let adapter = Arc::new(CliExecutorAdapter(executor)); + let config = agentmail_sdk::ClientConfig::default(); + let http_client = agentmail_sdk::HttpClient::with_executor( + adapter as Arc, + config.clone(), + ); + agentmail_sdk::api::ApiClient { + config, + inboxes: agentmail_sdk::api::InboxesClient { + http_client: http_client.clone(), + api_keys: agentmail_sdk::api::resources::inboxes::ApiKeysClient2 { http_client: http_client.clone() }, + drafts: agentmail_sdk::api::resources::inboxes::DraftsClient2 { http_client: http_client.clone() }, + events: agentmail_sdk::api::resources::inboxes::EventsClient { http_client: http_client.clone() }, + lists: agentmail_sdk::api::resources::inboxes::ListsClient2 { http_client: http_client.clone() }, + messages: agentmail_sdk::api::resources::inboxes::MessagesClient { http_client: http_client.clone() }, + metrics: agentmail_sdk::api::resources::inboxes::MetricsClient2 { http_client: http_client.clone() }, + threads: agentmail_sdk::api::resources::inboxes::ThreadsClient2 { http_client: http_client.clone() }, + webhooks: agentmail_sdk::api::resources::inboxes::WebhooksClient2 { http_client: http_client.clone() }, + }, + pods: agentmail_sdk::api::PodsClient { + http_client: http_client.clone(), + api_keys: agentmail_sdk::api::resources::pods::ApiKeysClient3 { http_client: http_client.clone() }, + domains: agentmail_sdk::api::resources::pods::DomainsClient2 { http_client: http_client.clone() }, + drafts: agentmail_sdk::api::resources::pods::DraftsClient3 { http_client: http_client.clone() }, + inboxes: agentmail_sdk::api::resources::pods::InboxesClient2 { http_client: http_client.clone() }, + lists: agentmail_sdk::api::resources::pods::ListsClient3 { http_client: http_client.clone() }, + metrics: agentmail_sdk::api::resources::pods::MetricsClient3 { http_client: http_client.clone() }, + threads: agentmail_sdk::api::resources::pods::ThreadsClient3 { http_client: http_client.clone() }, + webhooks: agentmail_sdk::api::resources::pods::WebhooksClient3 { http_client: http_client.clone() }, + }, + webhooks: agentmail_sdk::api::WebhooksClient { http_client: http_client.clone() }, + agent: agentmail_sdk::api::AgentClient { http_client: http_client.clone() }, + api_keys: agentmail_sdk::api::ApiKeysClient { http_client: http_client.clone() }, + auth: agentmail_sdk::api::AuthClient { http_client: http_client.clone() }, + domains: agentmail_sdk::api::DomainsClient { http_client: http_client.clone() }, + drafts: agentmail_sdk::api::DraftsClient { http_client: http_client.clone() }, + lists: agentmail_sdk::api::ListsClient { http_client: http_client.clone() }, + metrics: agentmail_sdk::api::MetricsClient { http_client: http_client.clone() }, + organizations: agentmail_sdk::api::OrganizationsClient { http_client: http_client.clone() }, + threads: agentmail_sdk::api::ThreadsClient { http_client: http_client.clone() }, + } +} + +// --------------------------------------------------------------------------- +// block_on — async SDK call → sync handler result +// --------------------------------------------------------------------------- + +/// Execute an async SDK operation from a synchronous custom-command handler. +/// +/// Bridges the SDK's `ApiError` into the CLI's `CliError` so `?` works +/// naturally in handler bodies. +pub fn block_on(future: F) -> Result +where + F: Future>, +{ + tokio::task::block_in_place(|| { + let handle = tokio::runtime::Handle::current(); + handle.block_on(future).map_err(convert_api_error) + }) +} + +fn convert_api_error(e: agentmail_sdk::ApiError) -> CliError { + match e { + agentmail_sdk::ApiError::Http { status, message } => CliError::Api { + code: status, + message, + reason: http_status_reason(status).to_string(), + }, + agentmail_sdk::ApiError::Network(err) => { + CliError::Other(anyhow::anyhow!("SDK network error: {err}")) + } + agentmail_sdk::ApiError::Executor(boxed) => match boxed.downcast::() { + Ok(sdk_error) => sdk_error.into_cli_error(), + Err(other) => CliError::Other(anyhow::anyhow!("SDK executor error: {other}")), + }, + other => CliError::Other(anyhow::anyhow!("SDK error: {other}")), + } +} + +fn http_status_reason(status: u16) -> &'static str { + match status { + 400 => "badRequest", + 401 => "unauthorized", + 403 => "forbidden", + 404 => "notFound", + 408 => "requestTimeout", + 409 => "conflict", + 429 => "tooManyRequests", + 500 => "internalServerError", + 502 => "badGateway", + 503 => "serviceUnavailable", + 504 => "gatewayTimeout", + _ => "httpError", + } +} diff --git a/cmd/agentmail/banner.go b/cmd/agentmail/banner.go deleted file mode 100644 index 7aabb02..0000000 --- a/cmd/agentmail/banner.go +++ /dev/null @@ -1,20 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -const banner = ` - ___ __ __ _ __ - / _ |___ ____ ___ ____/ /_/ |/ __(_) / - / __ / _ ` + "`" + `/ -_) _ \/ __/ __/ /|_/ / _ / / -/_/ |_\_, /\__/_//_/\__/\__/_/ /_/\__/_/ - /___/ -` - -func init() { - if len(os.Args) == 1 { - fmt.Print(banner) - } -} diff --git a/cmd/agentmail/main.go b/cmd/agentmail/main.go deleted file mode 100644 index 6cb588d..0000000 --- a/cmd/agentmail/main.go +++ /dev/null @@ -1,74 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package main - -import ( - "context" - "errors" - "fmt" - "net/http" - "os" - "slices" - - "github.com/agentmail-to/agentmail-cli/pkg/cmd" - "github.com/agentmail-to/agentmail-go" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -func main() { - app := cmd.Command - - if slices.Contains(os.Args, "__complete") { - prepareForAutocomplete(app) - } - - if baseURL, ok := os.LookupEnv("AGENTMAIL_BASE_URL"); ok { - if err := cmd.ValidateBaseURL(baseURL, "AGENTMAIL_BASE_URL"); err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - os.Exit(1) - } - } - - if err := app.Run(context.Background(), os.Args); err != nil { - exitCode := 1 - - // Check if error has a custom exit code - if exitErr, ok := err.(cli.ExitCoder); ok { - exitCode = exitErr.ExitCode() - } - - var apierr *agentmail.Error - if errors.As(err, &apierr) { - fmt.Fprintf(os.Stderr, "%s %q: %d %s\n", apierr.Request.Method, apierr.Request.URL, apierr.Response.StatusCode, http.StatusText(apierr.Response.StatusCode)) - format := app.String("format-error") - json := gjson.Parse(apierr.RawJSON()) - show_err := cmd.ShowJSON(json, cmd.ShowJSONOpts{ - ExplicitFormat: app.IsSet("format-error"), - Format: format, - Title: "Error", - Transform: app.String("transform-error"), - }) - if show_err != nil { - // Just print the original error: - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - } - } else { - if cmd.CommandErrorBuffer.Len() > 0 { - os.Stderr.Write(cmd.CommandErrorBuffer.Bytes()) - } else { - fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - } - } - os.Exit(exitCode) - } -} - -func prepareForAutocomplete(cmd *cli.Command) { - // urfave/cli does not handle flag completions and will print an error if we inspect a command with invalid flags. - // This skips that sort of validation - cmd.SkipFlagParsing = true - for _, child := range cmd.Commands { - prepareForAutocomplete(child) - } -} diff --git a/dist-workspace.toml b/dist-workspace.toml new file mode 100644 index 0000000..f220b5e --- /dev/null +++ b/dist-workspace.toml @@ -0,0 +1,36 @@ +[workspace] +members = ["cargo:.", "cargo:agentmail-types", "cargo:agentmail-sdk"] + +# Config for 'dist' +[dist] +# The preferred dist version to use in CI (Cargo.toml SemVer syntax) +cargo-dist-version = "0.31.0" +# CI backends to support +ci = "github" +# Build each app with `cargo build --package` instead of a workspace-wide +# build. Without this, cargo-dist also compiles the non-distributed root +# crate, whose default features pull in native-tls/OpenSSL and break the +# musl builds. +precise-builds = true +# The installers to generate for each app +installers = ["shell", "powershell"] +# Whether to enable GitHub Attestations +github-attestations = true +# Target platforms to build apps for (Rust target-triple syntax) +targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] +# Which actions to run on pull requests +pr-run-mode = "plan" +# Publish jobs to run (npm publishing deferred until pipeline is validated) +publish-jobs = [] +# Don't overwrite release.yml on `dist init` (preserves customizations) +allow-dirty = ["ci"] +# The archive format to use for windows builds (defaults .zip) +windows-archive = ".zip" +# The archive format to use for non-windows builds (defaults .tar.xz) +unix-archive = ".tar.gz" +# Path that installers should place binaries in +install-path = "CARGO_HOME" +# Whether to install an updater program +install-updater = false +default-features = false +features = ["rustls"] diff --git a/go.mod b/go.mod deleted file mode 100644 index aceb44f..0000000 --- a/go.mod +++ /dev/null @@ -1,46 +0,0 @@ -module github.com/agentmail-to/agentmail-cli - -go 1.25 - -require ( - github.com/agentmail-to/agentmail-go v0.16.0 - github.com/charmbracelet/bubbles v0.21.0 - github.com/charmbracelet/bubbletea v1.3.6 - github.com/charmbracelet/lipgloss v1.1.0 - github.com/charmbracelet/x/term v0.2.1 - github.com/goccy/go-yaml v1.18.0 - github.com/itchyny/json2yaml v0.1.4 - github.com/muesli/reflow v0.3.0 - github.com/stretchr/testify v1.10.0 - github.com/tidwall/gjson v1.18.0 - github.com/tidwall/pretty v1.2.1 - github.com/urfave/cli-docs/v3 v3.0.0-alpha6 - github.com/urfave/cli/v3 v3.3.2 - golang.org/x/sys v0.38.0 -) - -require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/sjson v1.2.5 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/text v0.3.8 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index b9954b5..0000000 --- a/go.sum +++ /dev/null @@ -1,89 +0,0 @@ -github.com/agentmail-to/agentmail-go v0.16.0 h1:L3FBYX4yXaxReA5GS2wgwrmZpDGFIKHGLmrs30nGjY4= -github.com/agentmail-to/agentmail-go v0.16.0/go.mod h1:3NrKbeXLQKRgb9gj2bmCoN9WXDTy9y9yacV070xpvDU= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= -github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= -github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= -github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/itchyny/json2yaml v0.1.4 h1:/pErVOXGG5iTyXHi/QKR4y3uzhLjGTEmmJIy97YT+k8= -github.com/itchyny/json2yaml v0.1.4/go.mod h1:6iudhBZdarpjLFRNj+clWLAkGft+9uCcjAZYXUH9eGI= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= -github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= -github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= -github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI= -github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= -github.com/urfave/cli/v3 v3.3.2 h1:BYFVnhhZ8RqT38DxEYVFPPmGFTEf7tJwySTXsVRrS/o= -github.com/urfave/cli/v3 v3.3.2/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/apiform/encoder.go b/internal/apiform/encoder.go deleted file mode 100644 index 857fe14..0000000 --- a/internal/apiform/encoder.go +++ /dev/null @@ -1,236 +0,0 @@ -package apiform - -import ( - "fmt" - "io" - "mime/multipart" - "net/textproto" - "path" - "reflect" - "sort" - "strconv" - "strings" -) - -// Marshal encodes a value as multipart form data using default settings -func Marshal(value any, writer *multipart.Writer) error { - e := &encoder{ - format: FormatRepeat, - } - return e.marshal(value, writer) -} - -// MarshalWithSettings encodes a value with custom array format -func MarshalWithSettings(value any, writer *multipart.Writer, arrayFormat FormFormat) error { - e := &encoder{ - format: arrayFormat, - } - return e.marshal(value, writer) -} - -type encoder struct { - format FormFormat -} - -func (e *encoder) marshal(value any, writer *multipart.Writer) error { - val := reflect.ValueOf(value) - if !val.IsValid() { - return nil - } - return e.encodeValue("", val, writer) -} - -func (e *encoder) encodeValue(key string, val reflect.Value, writer *multipart.Writer) error { - if !val.IsValid() { - return writer.WriteField(key, "") - } - - t := val.Type() - - if t.Implements(reflect.TypeOf((*io.Reader)(nil)).Elem()) { - return e.encodeReader(key, val, writer) - } - - switch t.Kind() { - case reflect.Pointer: - if val.IsNil() || !val.IsValid() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - - case reflect.Slice, reflect.Array: - return e.encodeArray(key, val, writer) - - case reflect.Map: - return e.encodeMap(key, val, writer) - - case reflect.Interface: - if val.IsNil() { - return writer.WriteField(key, "") - } - return e.encodeValue(key, val.Elem(), writer) - - case reflect.String: - return writer.WriteField(key, val.String()) - - case reflect.Bool: - if val.Bool() { - return writer.WriteField(key, "true") - } - return writer.WriteField(key, "false") - - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return writer.WriteField(key, strconv.FormatInt(val.Int(), 10)) - - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return writer.WriteField(key, strconv.FormatUint(val.Uint(), 10)) - - case reflect.Float32: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 32)) - - case reflect.Float64: - return writer.WriteField(key, strconv.FormatFloat(val.Float(), 'f', -1, 64)) - - default: - return fmt.Errorf("unknown type: %s", t.String()) - } -} - -func (e *encoder) encodeArray(key string, val reflect.Value, writer *multipart.Writer) error { - if e.format == FormatComma { - var values []string - for i := 0; i < val.Len(); i++ { - item := val.Index(i) - if (item.Kind() == reflect.Pointer || item.Kind() == reflect.Interface) && item.IsNil() { - // Null values are sent as an empty string - values = append(values, "") - continue - } - // If item is an interface, reduce it to the concrete type - if item.Kind() == reflect.Interface { - item = item.Elem() - } - var strValue string - switch item.Kind() { - case reflect.String: - strValue = item.String() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - strValue = strconv.FormatInt(item.Int(), 10) - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - strValue = strconv.FormatUint(item.Uint(), 10) - case reflect.Float32, reflect.Float64: - strValue = strconv.FormatFloat(item.Float(), 'f', -1, 64) - case reflect.Bool: - strValue = strconv.FormatBool(item.Bool()) - default: - return fmt.Errorf("comma format not supported for complex array elements") - } - values = append(values, strValue) - } - return writer.WriteField(key, strings.Join(values, ",")) - } - - for i := 0; i < val.Len(); i++ { - var formattedKey string - switch e.format { - case FormatRepeat: - formattedKey = key - case FormatBrackets: - formattedKey = key + "[]" - case FormatIndicesDots: - if key == "" { - formattedKey = strconv.Itoa(i) - } else { - formattedKey = key + "." + strconv.Itoa(i) - } - case FormatIndicesBrackets: - if key == "" { - formattedKey = strconv.Itoa(i) - } else { - formattedKey = key + "[" + strconv.Itoa(i) + "]" - } - default: - return fmt.Errorf("apiform: unsupported array format") - } - - if err := e.encodeValue(formattedKey, val.Index(i), writer); err != nil { - return err - } - } - return nil -} - -var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") - -func escapeQuotes(s string) string { - return quoteEscaper.Replace(s) -} - -func (e *encoder) encodeReader(key string, val reflect.Value, writer *multipart.Writer) error { - reader, ok := val.Convert(reflect.TypeOf((*io.Reader)(nil)).Elem()).Interface().(io.Reader) - if !ok { - return nil - } - - // Set defaults - filename := "anonymous_file" - contentType := "application/octet-stream" - - // Get filename if available - if named, ok := reader.(interface{ Filename() string }); ok { - filename = named.Filename() - } else if named, ok := reader.(interface{ Name() string }); ok { - filename = path.Base(named.Name()) - } - - // Get content type if available - if typed, ok := reader.(interface{ ContentType() string }); ok { - contentType = typed.ContentType() - } - - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, - escapeQuotes(key), escapeQuotes(filename))) - h.Set("Content-Type", contentType) - - filewriter, err := writer.CreatePart(h) - if err != nil { - return err - } - _, err = io.Copy(filewriter, reader) - return err -} - -func (e *encoder) encodeMap(key string, val reflect.Value, writer *multipart.Writer) error { - type mapPair struct { - key string - value reflect.Value - } - - if key != "" { - key = key + "." - } - - // Collect and sort map entries for deterministic output - pairs := []mapPair{} - iter := val.MapRange() - for iter.Next() { - if iter.Key().Type().Kind() != reflect.String { - return fmt.Errorf("cannot encode a map with a non string key") - } - pairs = append(pairs, mapPair{key: iter.Key().String(), value: iter.Value()}) - } - - sort.Slice(pairs, func(i, j int) bool { - return pairs[i].key < pairs[j].key - }) - - // Process sorted pairs - for _, p := range pairs { - if err := e.encodeValue(key+p.key, p.value, writer); err != nil { - return err - } - } - - return nil -} diff --git a/internal/apiform/form.go b/internal/apiform/form.go deleted file mode 100644 index 024de27..0000000 --- a/internal/apiform/form.go +++ /dev/null @@ -1,20 +0,0 @@ -package apiform - -type Marshaler interface { - MarshalMultipart() ([]byte, string, error) -} - -type FormFormat int - -const ( - // FormatRepeat represents arrays as repeated keys with the same value - FormatRepeat FormFormat = iota - // Comma-separated values 1,2,3 - FormatComma - // FormatBrackets uses the key[] notation for arrays - FormatBrackets - // FormatIndicesDots uses key.0, key.1, etc. notation - FormatIndicesDots - // FormatIndicesBrackets uses key[0], key[1], etc. notation - FormatIndicesBrackets -) diff --git a/internal/apiform/form_test.go b/internal/apiform/form_test.go deleted file mode 100644 index f68cfd1..0000000 --- a/internal/apiform/form_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package apiform - -import ( - "bytes" - "mime/multipart" - "testing" -) - -// Define test cases -var tests = map[string]struct { - value any - format FormFormat - expected string -}{ - "nil": { - value: nil, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n\r\n--xxx--\r\n", - }, - "string": { - value: "hello", - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\nhello\r\n--xxx--\r\n", - }, - "int": { - value: 42, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n42\r\n--xxx--\r\n", - }, - "float": { - value: 3.14, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n3.14\r\n--xxx--\r\n", - }, - "bool": { - value: true, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\ntrue\r\n--xxx--\r\n", - }, - "empty slice": { - value: []string{}, - expected: "\r\n--xxx--\r\n", - }, - "nil slice": { - value: []string(nil), - expected: "\r\n--xxx--\r\n", - }, - "slice with dot indices": { - value: []string{"a", "b", "c"}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.0\"\r\n\r\na\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.1\"\r\n\r\nb\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.2\"\r\n\r\nc\r\n--xxx--\r\n", - }, - "slice with bracket indices": { - value: []int{10, 20, 30}, - format: FormatIndicesBrackets, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo[0]\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[1]\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo[2]\"\r\n\r\n30\r\n--xxx--\r\n", - }, - "slice with repeat": { - value: []int{10, 20, 30}, - format: FormatRepeat, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n20\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n30\r\n--xxx--\r\n", - }, - "slice with commas": { - value: []int{10, 20, 30}, - format: FormatComma, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo\"\r\n\r\n10,20,30\r\n--xxx--\r\n", - }, - "empty map": { - value: map[string]any{}, - expected: "\r\n--xxx--\r\n", - }, - "nil map": { - value: map[string]any(nil), - expected: "\r\n--xxx--\r\n", - }, - "map": { - value: map[string]any{"key1": "value1", "key2": "value2"}, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.key1\"\r\n\r\nvalue1\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.key2\"\r\n\r\nvalue2\r\n--xxx--\r\n", - }, - "nested_map": { - value: map[string]any{"outer": map[string]int{"inner1": 10, "inner2": 20}}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner1\"\r\n\r\n10\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.outer.inner2\"\r\n\r\n20\r\n--xxx--\r\n", - }, - "mixed_map": { - value: map[string]any{"name": "John", "ages": []int{25, 30, 35}}, - format: FormatIndicesDots, - expected: "--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.0\"\r\n\r\n25\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.1\"\r\n\r\n30\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.ages.2\"\r\n\r\n35\r\n--xxx\r\nContent-Disposition: form-data; name=\"foo.name\"\r\n\r\nJohn\r\n--xxx--\r\n", - }, -} - -func TestEncode(t *testing.T) { - t.Parallel() - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - buf := bytes.NewBuffer(nil) - writer := multipart.NewWriter(buf) - writer.SetBoundary("xxx") - - form := map[string]any{"foo": test.value} - err := MarshalWithSettings(form, writer, test.format) - if err != nil { - t.Errorf("serialization of %v failed with error %v", test.value, err) - } - err = writer.Close() - if err != nil { - t.Errorf("serialization of %v failed with error %v", test.value, err) - } - result := buf.String() - if result != test.expected { - t.Errorf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.value, test.expected, result) - } - }) - } -} diff --git a/internal/apiquery/encoder.go b/internal/apiquery/encoder.go deleted file mode 100644 index 0d09dee..0000000 --- a/internal/apiquery/encoder.go +++ /dev/null @@ -1,166 +0,0 @@ -package apiquery - -import ( - "fmt" - "reflect" - "strconv" - "strings" -) - -type encoder struct { - settings QuerySettings -} - -type Pair struct { - key string - value string -} - -func (e *encoder) Encode(key string, value reflect.Value) ([]Pair, error) { - t := value.Type() - switch t.Kind() { - case reflect.Pointer: - if value.IsNil() || !value.IsValid() { - return []Pair{{key, ""}}, nil - } - return e.Encode(key, value.Elem()) - - case reflect.Array, reflect.Slice: - return e.encodeArray(key, value) - - case reflect.Map: - return e.encodeMap(key, value) - - case reflect.Interface: - if !value.Elem().IsValid() { - return []Pair{{key, ""}}, nil - } - return e.Encode(key, value.Elem()) - - default: - return e.encodePrimitive(key, value) - } -} - -func (e *encoder) encodeMap(key string, value reflect.Value) ([]Pair, error) { - var pairs []Pair - iter := value.MapRange() - for iter.Next() { - subkey := iter.Key().String() - keyPath := subkey - if len(key) > 0 { - if e.settings.NestedFormat == NestedQueryFormatDots { - keyPath = fmt.Sprintf("%s.%s", key, subkey) - } else { - keyPath = fmt.Sprintf("%s[%s]", key, subkey) - } - } - - subpairs, err := e.Encode(keyPath, iter.Value()) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil -} - -func (e *encoder) encodeArray(key string, value reflect.Value) ([]Pair, error) { - switch e.settings.ArrayFormat { - case ArrayQueryFormatComma: - elements := []string{} - for i := 0; i < value.Len(); i++ { - innerPairs, err := e.Encode("", value.Index(i)) - if err != nil { - return nil, err - } - for _, pair := range innerPairs { - elements = append(elements, pair.value) - } - } - return []Pair{{key, strings.Join(elements, ",")}}, nil - - case ArrayQueryFormatRepeat: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(key, value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - case ArrayQueryFormatIndices: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(fmt.Sprintf("%s[%d]", key, i), value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - case ArrayQueryFormatBrackets: - var pairs []Pair - for i := 0; i < value.Len(); i++ { - subpairs, err := e.Encode(key+"[]", value.Index(i)) - if err != nil { - return nil, err - } - pairs = append(pairs, subpairs...) - } - return pairs, nil - - default: - panic(fmt.Sprintf("Unknown ArrayFormat value: %d", e.settings.ArrayFormat)) - } -} - -func (e *encoder) encodePrimitive(key string, value reflect.Value) ([]Pair, error) { - switch value.Kind() { - case reflect.Pointer: - if !value.IsValid() || value.IsNil() { - return nil, nil - } - return e.encodePrimitive(key, value.Elem()) - - case reflect.String: - return []Pair{{key, value.String()}}, nil - - case reflect.Bool: - if value.Bool() { - return []Pair{{key, "true"}}, nil - } - return []Pair{{key, "false"}}, nil - - case reflect.Int, reflect.Int16, reflect.Int32, reflect.Int64: - return []Pair{{key, strconv.FormatInt(value.Int(), 10)}}, nil - - case reflect.Uint, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return []Pair{{key, strconv.FormatUint(value.Uint(), 10)}}, nil - - case reflect.Float32, reflect.Float64: - return []Pair{{key, strconv.FormatFloat(value.Float(), 'f', -1, 64)}}, nil - - default: - return nil, nil - } -} - -func (e *encoder) encodeField(key string, value reflect.Value) ([]Pair, error) { - present := value.FieldByName("Present") - if !present.Bool() { - return nil, nil - } - null := value.FieldByName("Null") - if null.Bool() { - return nil, fmt.Errorf("apiquery: field cannot be null") - } - raw := value.FieldByName("Raw") - if !raw.IsNil() { - return e.Encode(key, raw) - } - return e.Encode(key, value.FieldByName("Value")) -} diff --git a/internal/apiquery/query.go b/internal/apiquery/query.go deleted file mode 100644 index fd07a2f..0000000 --- a/internal/apiquery/query.go +++ /dev/null @@ -1,53 +0,0 @@ -package apiquery - -import ( - "net/url" - "reflect" -) - -func MarshalWithSettings(value any, settings QuerySettings) (url.Values, error) { - val := reflect.ValueOf(value) - if !val.IsValid() { - return nil, nil - } - - e := encoder{settings} - pairs, err := e.Encode("", val) - if err != nil { - return nil, err - } - - kv := url.Values{} - for _, pair := range pairs { - kv.Add(pair.key, pair.value) - } - return kv, nil -} -func Marshal(value any) (url.Values, error) { - return MarshalWithSettings(value, QuerySettings{}) -} - -type Queryer interface { - URLQuery() (url.Values, error) -} - -type NestedQueryFormat int - -const ( - NestedQueryFormatBrackets NestedQueryFormat = iota - NestedQueryFormatDots -) - -type ArrayQueryFormat int - -const ( - ArrayQueryFormatComma ArrayQueryFormat = iota - ArrayQueryFormatRepeat - ArrayQueryFormatIndices - ArrayQueryFormatBrackets -) - -type QuerySettings struct { - NestedFormat NestedQueryFormat - ArrayFormat ArrayQueryFormat -} diff --git a/internal/apiquery/query_test.go b/internal/apiquery/query_test.go deleted file mode 100644 index 3791ec9..0000000 --- a/internal/apiquery/query_test.go +++ /dev/null @@ -1,132 +0,0 @@ -package apiquery - -import ( - "net/url" - "testing" -) - -func TestEncode(t *testing.T) { - t.Parallel() - - tests := map[string]struct { - val any - settings QuerySettings - enc string - }{ - "null": { - val: nil, - enc: "query=", - }, - "string": { - val: "hello world", - enc: "query=hello world", - }, - "int": { - val: 42, - enc: "query=42", - }, - "float": { - val: 3.14, - enc: "query=3.14", - }, - "bool": { - val: true, - enc: "query=true", - }, - "empty_slice": { - val: []any{}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=", - }, - "nil_slice": { - val: []any(nil), - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=", - }, - "slice_of_ints": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma}, - enc: "query=10,20,30", - }, - "slice_of_ints_repeat": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat}, - enc: "query=10&query=20&query=30", - }, - "slice_of_ints_indices": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatIndices}, - enc: "query[0]=10&query[1]=20&query[2]=30", - }, - "slice_of_ints_brackets": { - val: []any{10, 20, 30}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatBrackets}, - enc: "query[]=10&query[]=20&query[]=30", - }, - "slice_of_strings": { - val: []any{"a", "b", "c"}, - settings: QuerySettings{}, - enc: "query=a,b,c", - }, - "empty_map": { - val: map[string]any{}, - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "", - }, - "nil_map": { - val: map[string]any(nil), - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "", - }, - "map_string_to_int_brackets": { - val: map[string]any{"one": 1, "two": 2}, - settings: QuerySettings{NestedFormat: NestedQueryFormatBrackets}, - enc: "query[one]=1&query[two]=2", - }, - "map_string_to_int_dots": { - val: map[string]any{"one": 1, "two": 2}, - settings: QuerySettings{NestedFormat: NestedQueryFormatDots}, - enc: "query.one=1&query.two=2", - }, - "map_string_to_slice": { - val: map[string][]any{"nums": {10, 20, 30}}, - settings: QuerySettings{}, - enc: "query[nums]=10,20,30", - }, - "map_string_to_slice_repeat_dots": { - val: map[string][]any{"nums": {10, 20, 30}}, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatRepeat, NestedFormat: NestedQueryFormatDots}, - enc: "query.nums=10&query.nums=20&query.nums=30", - }, - "map_with_empties": { - val: map[string]any{ - "empty-array": []any{}, - "nil-array": []any(nil), - "null": nil, - }, - settings: QuerySettings{ArrayFormat: ArrayQueryFormatComma, NestedFormat: NestedQueryFormatDots}, - enc: "query.empty-array=&query.nil-array=&query.null=", - }, - "nested_map": { - val: map[string]map[string]any{"outer": {"inner": 42}}, - settings: QuerySettings{}, - enc: "query[outer][inner]=42", - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - t.Parallel() - - query := map[string]any{"query": test.val} - values, err := MarshalWithSettings(query, test.settings) - if err != nil { - t.Fatalf("failed to marshal url %s", err) - } - str, _ := url.QueryUnescape(values.Encode()) - if str != test.enc { - t.Fatalf("expected %+#v to serialize to:\n\t%q\nbut got:\n\t%q", test.val, test.enc, str) - } - }) - } -} diff --git a/internal/autocomplete/autocomplete.go b/internal/autocomplete/autocomplete.go deleted file mode 100644 index 97fe1a8..0000000 --- a/internal/autocomplete/autocomplete.go +++ /dev/null @@ -1,361 +0,0 @@ -package autocomplete - -import ( - "context" - "embed" - "fmt" - "os" - "slices" - "strings" - - "github.com/urfave/cli/v3" -) - -type CompletionStyle string - -const ( - CompletionStyleZsh CompletionStyle = "zsh" - CompletionStyleBash CompletionStyle = "bash" - CompletionStylePowershell CompletionStyle = "pwsh" - CompletionStyleFish CompletionStyle = "fish" -) - -type renderCompletion func(cmd *cli.Command, appName string) (string, error) - -var ( - //go:embed shellscripts - autoCompleteFS embed.FS - - shellCompletions = map[CompletionStyle]renderCompletion{ - "bash": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/bash_autocomplete.bash") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "fish": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/fish_autocomplete.fish") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "pwsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/pwsh_autocomplete.ps1") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - "zsh": func(c *cli.Command, appName string) (string, error) { - b, err := autoCompleteFS.ReadFile("shellscripts/zsh_autocomplete.zsh") - return strings.ReplaceAll(string(b), "__APPNAME__", appName), err - }, - } -) - -func OutputCompletionScript(ctx context.Context, cmd *cli.Command) error { - shells := make([]CompletionStyle, 0, len(shellCompletions)) - for k := range shellCompletions { - shells = append(shells, k) - } - - if cmd.Args().Len() == 0 { - return cli.Exit(fmt.Sprintf("no shell provided for completion command. available shells are %+v", shells), 1) - } - s := CompletionStyle(cmd.Args().First()) - - renderCompletion, ok := shellCompletions[s] - if !ok { - return cli.Exit(fmt.Sprintf("unknown shell %s, available shells are %+v", s, shells), 1) - } - - completionScript, err := renderCompletion(cmd, cmd.Root().Name) - if err != nil { - return cli.Exit(err, 1) - } - - _, err = cmd.Writer.Write([]byte(completionScript)) - if err != nil { - return cli.Exit(err, 1) - } - - return nil -} - -type ShellCompletion struct { - Name string - Usage string -} - -func NewShellCompletion(name string, usage string) ShellCompletion { - return ShellCompletion{Name: name, Usage: usage} -} - -type ShellCompletionBehavior int - -const ( - ShellCompletionBehaviorDefault ShellCompletionBehavior = iota - ShellCompletionBehaviorFile = 10 - ShellCompletionBehaviorNoComplete -) - -type CompletionResult struct { - Completions []ShellCompletion - Behavior ShellCompletionBehavior -} - -func isFlag(arg string) bool { - return strings.HasPrefix(arg, "-") -} - -func findFlag(cmd *cli.Command, arg string) *cli.Flag { - name := strings.TrimLeft(arg, "-") - for _, flag := range cmd.Flags { - if vf, ok := flag.(cli.VisibleFlag); ok && !vf.IsVisible() { - continue - } - - if slices.Contains(flag.Names(), name) { - return &flag - } - } - return nil -} - -func findChild(cmd *cli.Command, name string) *cli.Command { - for _, c := range cmd.Commands { - if !c.Hidden && c.Name == name { - return c - } - } - return nil -} - -type shellCompletionBuilder struct { - completionStyle CompletionStyle -} - -func (scb *shellCompletionBuilder) createFromCommand(input string, command *cli.Command, result []ShellCompletion) []ShellCompletion { - matchingNames := make([]string, 0, len(command.Names())) - - for _, name := range command.Names() { - if strings.HasPrefix(name, input) { - matchingNames = append(matchingNames, name) - } - } - - if scb.completionStyle == CompletionStyleBash { - index := strings.LastIndex(input, ":") + 1 - if index > 0 { - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name[index:], command.Usage)) - } - return result - } - } - - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name, command.Usage)) - } - return result -} - -func (scb *shellCompletionBuilder) createFromFlag(input string, flag *cli.Flag, result []ShellCompletion) []ShellCompletion { - matchingNames := make([]string, 0, len((*flag).Names())) - - for _, name := range (*flag).Names() { - withPrefix := "" - if len(name) == 1 { - withPrefix = "-" + name - } else { - withPrefix = "--" + name - } - - if strings.HasPrefix(withPrefix, input) { - matchingNames = append(matchingNames, withPrefix) - } - } - - usage := "" - if dgf, ok := (*flag).(cli.DocGenerationFlag); ok { - usage = dgf.GetUsage() - } - - for _, name := range matchingNames { - result = append(result, NewShellCompletion(name, usage)) - } - - return result -} - -func GetCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { - result := getAllPossibleCompletions(completionStyle, root, args) - - // If the user has not put in a colon, filter out colon commands - if len(args) > 0 && !strings.Contains(args[len(args)-1], ":") { - // Nothing with anything after a colon. Create a single entry for groups with the same colon subset - foundNames := make([]string, 0, len(result.Completions)) - filteredCompletions := make([]ShellCompletion, 0, len(result.Completions)) - - for _, completion := range result.Completions { - name := completion.Name - firstColonIndex := strings.Index(name, ":") - if firstColonIndex > -1 { - name = name[0:firstColonIndex] - completion.Name = name - completion.Usage = "" - } - - if !slices.Contains(foundNames, name) { - foundNames = append(foundNames, name) - filteredCompletions = append(filteredCompletions, completion) - } - } - - result.Completions = filteredCompletions - } - - return result -} - -func getAllPossibleCompletions(completionStyle CompletionStyle, root *cli.Command, args []string) CompletionResult { - builder := shellCompletionBuilder{completionStyle: completionStyle} - completions := make([]ShellCompletion, 0) - if len(args) == 0 { - for _, child := range root.Commands { - completions = builder.createFromCommand("", child, completions) - } - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorDefault} - } - - current := args[len(args)-1] - preceding := args[0 : len(args)-1] - cmd := root - i := 0 - for i < len(preceding) { - arg := preceding[i] - - if isFlag(arg) { - flag := findFlag(cmd, arg) - if flag == nil { - i++ - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { - // All flags except for bool flags take values - i += 2 - } else { - i++ - } - } else { - child := findChild(cmd, arg) - if child != nil { - cmd = child - } - i++ - } - } - - // Check if the previous arg was a flag expecting a value - if len(preceding) > 0 { - prev := preceding[len(preceding)-1] - if isFlag(prev) { - flag := findFlag(cmd, prev) - if flag != nil { - if fb, ok := (*flag).(*cli.StringFlag); ok && fb.TakesFile { - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorFile} - } else if docFlag, ok := (*flag).(cli.DocGenerationFlag); ok && docFlag.TakesValue() { - return CompletionResult{Completions: completions, Behavior: ShellCompletionBehaviorNoComplete} - } - } - } - } - - // Completing a flag name - if isFlag(current) { - for _, flag := range cmd.Flags { - completions = builder.createFromFlag(current, &flag, completions) - } - } - - for _, child := range cmd.Commands { - if !child.Hidden { - completions = builder.createFromCommand(current, child, completions) - } - } - - return CompletionResult{ - Completions: completions, - Behavior: ShellCompletionBehaviorDefault, - } -} - -func ExecuteShellCompletion(ctx context.Context, cmd *cli.Command) error { - root := cmd.Root() - args := rebuildColonSeparatedArgs(root.Args().Slice()[1:]) - - var completionStyle CompletionStyle - if style, ok := os.LookupEnv("COMPLETION_STYLE"); ok { - switch style { - case "bash": - completionStyle = CompletionStyleBash - case "zsh": - completionStyle = CompletionStyleZsh - case "pwsh": - completionStyle = CompletionStylePowershell - case "fish": - completionStyle = CompletionStyleFish - default: - return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', or 'fish'", 1) - } - } else { - return cli.Exit("COMPLETION_STYLE must be set to 'bash', 'zsh', 'pwsh', 'fish'", 1) - } - - result := GetCompletions(completionStyle, root, args) - - for _, completion := range result.Completions { - name := completion.Name - if completionStyle == CompletionStyleZsh { - name = strings.ReplaceAll(name, ":", "\\:") - } - if completionStyle == CompletionStyleZsh && len(completion.Usage) > 0 { - _, _ = fmt.Fprintf(cmd.Writer, "%s:%s\n", name, completion.Usage) - } else if completionStyle == CompletionStyleFish && len(completion.Usage) > 0 { - _, _ = fmt.Fprintf(cmd.Writer, "%s\t%s\n", name, completion.Usage) - } else { - _, _ = fmt.Fprintf(cmd.Writer, "%s\n", name) - } - } - return cli.Exit("", int(result.Behavior)) -} - -// When CLI arguments are passed in, they are separated on word barriers. -// Most commonly this is whitespace but in some cases that may also be colons. -// We wish to allow arguments with colons. To handle this, we append/prepend colons to their neighboring -// arguments. -// -// Example: `rebuildColonSeparatedArgs(["a", "b", ":", "c", "d"])` => `["a", "b:c", "d"]` -func rebuildColonSeparatedArgs(args []string) []string { - if len(args) == 0 { - return args - } - - result := []string{} - i := 0 - - for i < len(args) { - current := args[i] - - // Keep joining while the next element is ":" or the current element ends with ":" - for i+1 < len(args) && (args[i+1] == ":" || strings.HasSuffix(current, ":")) { - if args[i+1] == ":" { - current += ":" - i++ - // Check if there's a following element after the ":" - if i+1 < len(args) && args[i+1] != ":" { - current += args[i+1] - i++ - } - } else { - break - } - } - - result = append(result, current) - i++ - } - - return result -} diff --git a/internal/autocomplete/autocomplete_test.go b/internal/autocomplete/autocomplete_test.go deleted file mode 100644 index 2338924..0000000 --- a/internal/autocomplete/autocomplete_test.go +++ /dev/null @@ -1,433 +0,0 @@ -package autocomplete - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestGetCompletions_EmptyArgs(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "test", Usage: "Run tests"}, - {Name: "build", Usage: "Build project"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 3) - assert.Contains(t, result.Completions, ShellCompletion{Name: "generate", Usage: "Generate SDK"}) - assert.Contains(t, result.Completions, ShellCompletion{Name: "test", Usage: "Run tests"}) - assert.Contains(t, result.Completions, ShellCompletion{Name: "build", Usage: "Build project"}) -} - -func TestGetCompletions_SubcommandPrefix(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "test", Usage: "Run tests"}, - {Name: "build", Usage: "Build project"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"ge"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "generate", result.Completions[0].Name) - assert.Equal(t, "Generate SDK", result.Completions[0].Usage) -} - -func TestGetCompletions_HiddenCommand(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "visible", Usage: "Visible command"}, - {Name: "hidden", Usage: "Hidden command", Hidden: true}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{""}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "visible", result.Completions[0].Name) -} - -func TestGetCompletions_NestedSubcommand(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "config", - Usage: "Configuration commands", - Commands: []*cli.Command{ - {Name: "get", Usage: "Get config value"}, - {Name: "set", Usage: "Set config value"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config", "s"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "set", result.Completions[0].Name) - assert.Equal(t, "Set config value", result.Completions[0].Usage) -} - -func TestGetCompletions_FlagCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - &cli.StringFlag{Name: "format", Usage: "Output format"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--o"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "--output", result.Completions[0].Name) - assert.Equal(t, "Output directory", result.Completions[0].Usage) -} - -func TestGetCompletions_ShortFlagCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}, Usage: "Output directory"}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "-v", result.Completions[0].Name) -} - -func TestGetCompletions_FileFlagBehavior(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "Config file", TakesFile: true}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--config", ""}) - - assert.EqualValues(t, ShellCompletionBehaviorFile, result.Behavior) - assert.Empty(t, result.Completions) -} - -func TestGetCompletions_NonBoolFlagValue(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "format", Usage: "Output format"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--format", ""}) - - assert.EqualValues(t, ShellCompletionBehaviorNoComplete, result.Behavior) - assert.Empty(t, result.Completions) -} - -func TestGetCompletions_BoolFlagDoesNotBlockCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "Verbose output"}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "Generate TypeScript SDK"}, - {Name: "python", Usage: "Generate Python SDK"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "--verbose", "ty"}) - - assert.Equal(t, ShellCompletionBehaviorDefault, result.Behavior) - assert.Len(t, result.Completions, 1) - assert.Equal(t, "typescript", result.Completions[0].Name) -} - -func TestGetCompletions_ColonCommands_NoColonTyped(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"co"}) - - // Should collapse to single "config" entry without usage - assert.Len(t, result.Completions, 1) - assert.Equal(t, "config", result.Completions[0].Name) - assert.Equal(t, "", result.Completions[0].Usage) -} - -func TestGetCompletions_ColonCommands_ColonTyped_Bash(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config:"}) - - // For bash, should show suffixes only - assert.Len(t, result.Completions, 3) - names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} - assert.Contains(t, names, "get") - assert.Contains(t, names, "set") - assert.Contains(t, names, "list") -} - -func TestGetCompletions_ColonCommands_ColonTyped_Zsh(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - {Name: "config:list", Usage: "List config values"}, - }, - } - - result := GetCompletions(CompletionStyleZsh, root, []string{"config:"}) - - // For zsh, should show full names - assert.Len(t, result.Completions, 3) - names := []string{result.Completions[0].Name, result.Completions[1].Name, result.Completions[2].Name} - assert.Contains(t, names, "config:get") - assert.Contains(t, names, "config:set") - assert.Contains(t, names, "config:list") -} - -func TestGetCompletions_BashStyleColonCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"config:g"}) - - // For bash, should return suffix from after the colon in the input - // Input "config:g" has colon at index 6, so we take name[7:] from matched commands - assert.Len(t, result.Completions, 1) - assert.Equal(t, "get", result.Completions[0].Name) - assert.Equal(t, "Get config value", result.Completions[0].Usage) -} - -func TestGetCompletions_BashStyleColonCompletion_NoMatch(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"other:g"}) - - // No matches - assert.Len(t, result.Completions, 0) -} - -func TestGetCompletions_ZshStyleColonCompletion(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleZsh, root, []string{"config:g"}) - - // For zsh, should return full name - assert.Len(t, result.Completions, 1) - assert.Equal(t, "config:get", result.Completions[0].Name) - assert.Equal(t, "Get config value", result.Completions[0].Usage) -} - -func TestGetCompletions_MixedColonAndRegularCommands(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Usage: "Generate SDK"}, - {Name: "config:get", Usage: "Get config value"}, - {Name: "config:set", Usage: "Set config value"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{""}) - - // Should show "generate" and "config" (collapsed) - assert.Len(t, result.Completions, 2) - names := []string{result.Completions[0].Name, result.Completions[1].Name} - assert.Contains(t, names, "generate") - assert.Contains(t, names, "config") -} - -func TestGetCompletions_FlagWithBoolFlagSkipsValue(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "TypeScript SDK"}, - }, - }, - }, - } - - // Bool flag should not consume the next arg as a value - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-v", "ty"}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "typescript", result.Completions[0].Name) -} - -func TestGetCompletions_MultipleFlagsBeforeSubcommand(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "config", Aliases: []string{"c"}}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - }, - Commands: []*cli.Command{ - {Name: "typescript", Usage: "TypeScript SDK"}, - {Name: "python", Usage: "Python SDK"}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-c", "config.yml", "-v", "py"}) - - assert.Len(t, result.Completions, 1) - assert.Equal(t, "python", result.Completions[0].Name) -} - -func TestGetCompletions_CommandAliases(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - {Name: "generate", Aliases: []string{"gen", "g"}, Usage: "Generate SDK"}, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"g"}) - - // Should match all aliases that start with "g" - assert.GreaterOrEqual(t, len(result.Completions), 2) // "generate" and "gen", possibly "g" too - names := []string{} - for _, c := range result.Completions { - names = append(names, c.Name) - } - assert.Contains(t, names, "generate") - assert.Contains(t, names, "gen") -} - -func TestGetCompletions_AllFlagsWhenNoPrefix(t *testing.T) { - t.Parallel() - - root := &cli.Command{ - Commands: []*cli.Command{ - { - Name: "generate", - Usage: "Generate SDK", - Flags: []cli.Flag{ - &cli.StringFlag{Name: "output", Aliases: []string{"o"}}, - &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}}, - &cli.StringFlag{Name: "format", Aliases: []string{"f"}}, - }, - }, - }, - } - - result := GetCompletions(CompletionStyleBash, root, []string{"generate", "-"}) - - // Should show all flag variations - assert.GreaterOrEqual(t, len(result.Completions), 6) // -o, --output, -v, --verbose, -f, --format -} diff --git a/internal/autocomplete/shellscripts/bash_autocomplete.bash b/internal/autocomplete/shellscripts/bash_autocomplete.bash deleted file mode 100755 index 8fb7b0b..0000000 --- a/internal/autocomplete/shellscripts/bash_autocomplete.bash +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash - -____APPNAME___bash_autocomplete() { - if [[ "${COMP_WORDS[0]}" != "source" ]]; then - local cur completions exit_code - local IFS=$'\n' - cur="${COMP_WORDS[COMP_CWORD]}" - - completions=$(COMPLETION_STYLE=bash "${COMP_WORDS[0]}" __complete -- "${COMP_WORDS[@]:1:$COMP_CWORD-1}" "$cur" 2>/dev/null) - exit_code=$? - - local last_token="$cur" - - # If the last token has been split apart by a ':', join it back together. - # Ex: 'a:b' will be represented in COMP_WORDS as 'a', ':', 'b' - if [[ $COMP_CWORD -ge 2 ]]; then - local prev2="${COMP_WORDS[COMP_CWORD - 2]}" - local prev1="${COMP_WORDS[COMP_CWORD - 1]}" - if [[ "$prev2" =~ ^@(file|data)$ && "$prev1" == ":" && "$cur" =~ ^// ]]; then - last_token="$prev2:$cur" - fi - fi - - # Check for custom file completion patterns - local prefix="" - local file_part="$cur" - local force_file_completion=false - if [[ "$last_token" =~ (.*)@(file://|data://)?(.*)$ ]]; then - local before_at="${BASH_REMATCH[1]}" - local protocol="${BASH_REMATCH[2]}" - file_part="${BASH_REMATCH[3]}" - - if [[ "$protocol" == "" ]]; then - prefix="$before_at@" - else - if [[ "$before_at" == "" ]]; then - prefix="//" - else - prefix="$before_at@$protocol" - fi - fi - - force_file_completion=true - fi - - if [[ "$force_file_completion" == true ]]; then - mapfile -t COMPREPLY < <(compgen -f -- "$file_part" | sed "s|^|$prefix|") - else - case $exit_code in - 10) mapfile -t COMPREPLY < <(compgen -f -- "$cur") ;; # file completion - 11) COMPREPLY=() ;; # no completion - 0) mapfile -t COMPREPLY <<<"$completions" ;; # use returned completions - esac - fi - return 0 - fi -} - -complete -F ____APPNAME___bash_autocomplete __APPNAME__ diff --git a/internal/autocomplete/shellscripts/fish_autocomplete.fish b/internal/autocomplete/shellscripts/fish_autocomplete.fish deleted file mode 100644 index b853057..0000000 --- a/internal/autocomplete/shellscripts/fish_autocomplete.fish +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env fish - -function ____APPNAME___fish_autocomplete - set -l tokens (commandline -xpc) - set -l current (commandline -ct) - - set -l cmd $tokens[1] - set -l args $tokens[2..-1] - - set -l completions (env COMPLETION_STYLE=fish $cmd __complete -- $args $current 2>>/tmp/fish-debug.log) - set -l exit_code $status - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - set -l prefix "" - set -l file_part "$current" - set -l force_file_completion 0 - - if string match -gqr '^(?.*)@(?file://|data://)?(?.*)$' -- $current - if string match -qr '^[\'"]' -- $before - # Ensures we don't insert an extra quote when the user is building an argument in quotes - set before (string sub -s 2 -- $before) - end - - set prefix "$before@$protocol" - set force_file_completion 1 - end - - if test $force_file_completion -eq 1 - for path in (__fish_complete_path "$file_part") - echo $prefix$path - end - else - switch $exit_code - case 10 - # File completion - __fish_complete_path "$current" - case 11 - # No completion - return 0 - case 0 - # Use returned completions - for completion in $completions - echo $completion - end - end - end -end - -complete -c __APPNAME__ -f -a '(____APPNAME___fish_autocomplete)' - diff --git a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 b/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 deleted file mode 100644 index 7cd6e62..0000000 --- a/internal/autocomplete/shellscripts/pwsh_autocomplete.ps1 +++ /dev/null @@ -1,97 +0,0 @@ -Register-ArgumentCompleter -Native -CommandName __APPNAME__ -ScriptBlock { - param($wordToComplete, $commandAst, $cursorPosition) - - $elements = $commandAst.CommandElements - $completionArgs = @() - - # Extract each of the arguments - for ($i = 0; $i -lt $elements.Count; $i++) { - $completionArgs += $elements[$i].Extent.Text - } - - # Add empty string if there's a trailing space (wordToComplete is empty but cursor is after space) - # Necessary for differentiating between getting completions for namespaced commands vs. subcommands - if ($wordToComplete.Length -eq 0 -and $elements.Count -gt 0) { - $completionArgs += "" - } - - $output = & { - $env:COMPLETION_STYLE = 'pwsh' - __APPNAME__ __complete @completionArgs 2>&1 - } - $exitCode = $LASTEXITCODE - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - $prefix = "" - $filePart = $wordToComplete - $forceFileCompletion = $false - - # PowerShell includes quotes in $wordToComplete - strip them for pattern matching - # but preserve them in the prefix for the completion result - $wordContent = $wordToComplete - $leadingQuote = "" - if ($wordToComplete -match '^([''"])(.*)(\1)$') { - # Fully quoted: "content" or 'content' - $leadingQuote = $Matches[1] - $wordContent = $Matches[2] - } elseif ($wordToComplete -match '^([''"])(.*)$') { - # Opening quote only: "content or 'content - $leadingQuote = $Matches[1] - $wordContent = $Matches[2] - } - - if ($wordContent -match '^(.*)@(file://|data://)?(.*)$') { - $prefix = $leadingQuote + $Matches[1] + '@' + $Matches[2] - $filePart = $Matches[3] - $forceFileCompletion = $true - } - - if ($forceFileCompletion) { - # Handle empty filePart (e.g., "@" or "@file://") by listing current directory - $items = if ([string]::IsNullOrEmpty($filePart)) { - Get-ChildItem -ErrorAction SilentlyContinue - } else { - Get-ChildItem -Path "$filePart*" -ErrorAction SilentlyContinue - } - $items | ForEach-Object { - $completionText = if ($_.PSIsContainer) { $prefix + $_.Name + "/" } else { $prefix + $_.Name } - [System.Management.Automation.CompletionResult]::new( - $completionText, - $completionText, - 'ProviderItem', - $completionText - ) - } - } else { - switch ($exitCode) { - 10 { - # File completion behavior - $items = if ([string]::IsNullOrEmpty($wordToComplete)) { - Get-ChildItem -ErrorAction SilentlyContinue - } else { - Get-ChildItem -Path "$wordToComplete*" -ErrorAction SilentlyContinue - } - $items | ForEach-Object { - $completionText = if ($_.PSIsContainer) { $_.Name + "/" } else { $_.Name } - [System.Management.Automation.CompletionResult]::new( - $completionText, - $completionText, - 'ProviderItem', - $completionText - ) - } - } - 11 { - # No reasonable suggestions - [System.Management.Automation.CompletionResult]::new(' ', ' ', 'ParameterValue', ' ') - } - default { - # Default behavior - show command completions - $output | ForEach-Object { - [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) - } - } - } - } -} diff --git a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh b/internal/autocomplete/shellscripts/zsh_autocomplete.zsh deleted file mode 100644 index d937171..0000000 --- a/internal/autocomplete/shellscripts/zsh_autocomplete.zsh +++ /dev/null @@ -1,56 +0,0 @@ -#compdef __APPNAME__ - -____APPNAME___zsh_autocomplete() { - - local -a opts - local temp - local exit_code - - temp=$(COMPLETION_STYLE=zsh "${words[1]}" __complete "${words[@]:1}") - exit_code=$? - - # Check for custom file completion patterns - # Patterns can appear anywhere in the word (e.g., inside quotes: 'my file is @file://path') - local cur="${words[CURRENT]}" - - if [[ "$cur" = *'@'* ]]; then - # Extract everything after the last @ - local after_last_at="${cur##*@}" - - if [[ $after_last_at =~ ^(file://|data://) ]]; then - compset -P "*$MATCH" - _files - else - compset -P '*@' - _files - fi - return - fi - - case $exit_code in - 10) - # File completion behavior - _files - ;; - 11) - # No completion behavior - return nothing - return 1 - ;; - 0) - # Default behavior - show command completions - opts=("${(@f)temp}") - _describe 'values' opts - ;; - esac -} - -# When installed in fpath (e.g., via Homebrew's zsh_completion stanza), this file -# is autoloaded as the function ___APPNAME__ and its body becomes that function's -# body. Detect that case via funcstack and dispatch to the completion function. -# When sourced (e.g., `source <(__APPNAME__ @completion zsh)`), register the -# function with compdef instead. -if [[ "${funcstack[1]}" = "___APPNAME__" ]]; then - ____APPNAME___zsh_autocomplete "$@" -else - compdef ____APPNAME___zsh_autocomplete __APPNAME__ -fi diff --git a/internal/binaryparam/binary_param.go b/internal/binaryparam/binary_param.go deleted file mode 100644 index 40d4ecf..0000000 --- a/internal/binaryparam/binary_param.go +++ /dev/null @@ -1,30 +0,0 @@ -package binaryparam - -import ( - "io" - "os" -) - -const stdinGlyph = "-" - -// FileOrStdin opens the file at the given path for reading. If the path is "-", stdin is returned instead. -// -// It's the caller's responsibility to close the returned ReadCloser (usually with `defer`). -// -// Returns a boolean indicating whether stdin is being used. If true, no other components of the calling -// program should attempt to read from stdin for anything else. -func FileOrStdin(stdin io.ReadCloser, path string) (io.ReadCloser, bool, error) { - // When the special glyph "-" is used, read from stdin. Although probably less necessary, also support - // special Unix files that refer to stdin. - switch path { - case "", stdinGlyph, "/dev/fd/0", "/dev/stdin": - return stdin, true, nil - } - - readCloser, err := os.Open(path) - if err != nil { - return nil, false, err - } - - return readCloser, false, err -} diff --git a/internal/binaryparam/binary_param_test.go b/internal/binaryparam/binary_param_test.go deleted file mode 100644 index 7a66682..0000000 --- a/internal/binaryparam/binary_param_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package binaryparam - -import ( - "io" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestFileOrStdin(t *testing.T) { - t.Parallel() - - const expectedContents = "test file contents" - - t.Run("WithFile", func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - readCloser, stdinInUse, err := FileOrStdin(os.Stdin, tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, readCloser.Close()) }) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.False(t, stdinInUse) - }) - - stdinTests := []struct { - testName string - path string - }{ - {"TestEmptyString", ""}, - {"TestDash", "-"}, - {"TestDevStdin", "/dev/stdin"}, - {"TestDevFD0", "/dev/fd/0"}, - } - for _, test := range stdinTests { - t.Run(test.testName, func(t *testing.T) { - tempFile := t.TempDir() + "/test_file.txt" - require.NoError(t, os.WriteFile(tempFile, []byte(expectedContents), 0600)) - - stubStdin, err := os.Open(tempFile) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, stubStdin.Close()) }) - - readCloser, stdinInUse, err := FileOrStdin(stubStdin, test.path) - require.NoError(t, err) - - actualContents, err := io.ReadAll(readCloser) - require.NoError(t, err) - require.Equal(t, expectedContents, string(actualContents)) - - require.True(t, stdinInUse) - }) - } -} diff --git a/internal/debugmiddleware/debug_middleware.go b/internal/debugmiddleware/debug_middleware.go deleted file mode 100644 index 647f1de..0000000 --- a/internal/debugmiddleware/debug_middleware.go +++ /dev/null @@ -1,132 +0,0 @@ -package debugmiddleware - -import ( - "bytes" - "io" - "log" - "net/http" - "net/http/httputil" - "reflect" - "strings" -) - -// For the time being these type definitions are duplicated here so that we can -// test this file in a non-generated context. -type ( - Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error) - MiddlewareNext = func(*http.Request) (*http.Response, error) -) - -const redactedPlaceholder = "" - -// Headers known to contain sensitive information like an API key. Note that this exclude `Authorization`, -// which is handled specially in `redactRequest` below. -var sensitiveHeaders = []string{ - "api-key", - "x-api-key", - "cookie", - "set-cookie", -} - -// RequestLogger is a middleware that logs HTTP requests and responses. -type RequestLogger struct { - logger interface{ Printf(string, ...any) } // field for testability; usually log.Default() - sensitiveHeaders []string // field for testability; usually sensitiveHeaders -} - -// NewRequestLogger returns a new RequestLogger instance with default options. -func NewRequestLogger() *RequestLogger { - return &RequestLogger{ - logger: log.Default(), - sensitiveHeaders: sensitiveHeaders, - } -} - -func (m *RequestLogger) Middleware() Middleware { - return func(req *http.Request, mn MiddlewareNext) (*http.Response, error) { - redacted, err := m.redactRequest(req) - if err != nil { - return nil, err - } - if reqBytes, err := httputil.DumpRequest(redacted, true); err == nil { - m.logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(req) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - m.logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - } -} - -// redactRequest redacts sensitive information from the request for logging -// purposes. If redaction is necessary, the request is cloned before mutating -// the original and that clone is returned. As a small optimization, the -// original is request is returned unchanged if no redaction is necessary. -func (m *RequestLogger) redactRequest(req *http.Request) (*http.Request, error) { - redactedHeaders := req.Header.Clone() - - // Notably, the clauses below are written so they can redact multiple - // headers of the same name if necessary. - if values := redactedHeaders.Values("Authorization"); len(values) > 0 { - redactedHeaders.Del("Authorization") - - for _, value := range values { - // In case we're using something like a bearer token (e.g. `Bearer - // `), keep the `Bearer` part for more debugging - // information. - if authKind, _, ok := strings.Cut(value, " "); ok { - redactedHeaders.Add("Authorization", authKind+" "+redactedPlaceholder) - } else { - redactedHeaders.Add("Authorization", redactedPlaceholder) - } - } - } - - for _, header := range m.sensitiveHeaders { - values := redactedHeaders.Values(header) - if len(values) == 0 { - continue - } - - redactedHeaders.Del(header) - - for range values { - redactedHeaders.Add(header, redactedPlaceholder) - } - } - - if reflect.DeepEqual(req.Header, redactedHeaders) { - return req, nil - } - - redacted := req.Clone(req.Context()) - redacted.Header = redactedHeaders - var err error - redacted.Body, req.Body, err = cloneBody(req.Body) - return redacted, err -} - -// This function returns two copies of an HTTP request body that can each be -// read independently without affecting the other. -// This logic is taken from `drainBody` in net/http/httputil. -func cloneBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) { - if b == nil || b == http.NoBody { - // No copying needed. Preserve the magic sentinel meaning of NoBody. - return http.NoBody, http.NoBody, nil - } - var buf bytes.Buffer - if _, err = buf.ReadFrom(b); err != nil { - return nil, b, err - } - if err = b.Close(); err != nil { - return nil, b, err - } - return io.NopCloser(&buf), io.NopCloser(bytes.NewReader(buf.Bytes())), nil -} diff --git a/internal/debugmiddleware/debug_middleware_test.go b/internal/debugmiddleware/debug_middleware_test.go deleted file mode 100644 index 4e46fbc..0000000 --- a/internal/debugmiddleware/debug_middleware_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package debugmiddleware - -import ( - "bytes" - "io" - "log" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDebugMiddleware(t *testing.T) { - t.Parallel() - - setup := func() (*RequestLogger, *bytes.Buffer) { - var ( - logBuf bytes.Buffer - middleware = NewRequestLogger() - ) - middleware.logger = log.New(&logBuf, "", 0) - return middleware, &logBuf - } - - t.Run("DoesNotRedactMostHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - const stainlessUserAgent = "Stainless" - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("User-Agent", stainlessUserAgent) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, stainlessUserAgent, req.Header.Get("User-Agent")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "User-Agent: "+stainlessUserAgent) - }) - - const secretToken = "secret-token" - - t.Run("RedactsAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("Authorization", secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) - }) - - t.Run("RedactsOnlySecretInAuthorizationHeader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set("Authorization", "Bearer "+secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: Bearer "+redactedPlaceholder) - }) - - t.Run("RedactsMultipleAuthorizationHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Add("Authorization", secretToken+"1") - req.Header.Add("Authorization", secretToken+"2") - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - - if strings.Count(logBuf.String(), "Authorization: "+redactedPlaceholder) != 2 { - t.Error("expected exactly two redacted placeholders in authorization headers") - } - }) - - const customAPIKeyHeader = "X-My-Api-Key" - - t.Run("RedactsSensitiveHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Set(customAPIKeyHeader, secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get(customAPIKeyHeader)) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder) - }) - - t.Run("RedactsMultipleSensitiveHeaders", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - req := httptest.NewRequest("GET", "https://example.com", nil) - req.Header.Add(customAPIKeyHeader, secretToken+"1") - req.Header.Add(customAPIKeyHeader, secretToken+"2") - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, []string{secretToken + "1", secretToken + "2"}, req.Header.Values(customAPIKeyHeader)) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Equal(t, 2, strings.Count(logBuf.String(), customAPIKeyHeader+": "+redactedPlaceholder)) - }) - - t.Run("DoesNotConsumeRequestBodyWhenIoReader", func(t *testing.T) { - t.Parallel() - - middleware, logBuf := setup() - middleware.sensitiveHeaders = []string{customAPIKeyHeader} - - const bodyContent = "test request body content" - bodyReader := strings.NewReader(bodyContent) - - req := httptest.NewRequest("POST", "https://example.com", bodyReader) - req.Header.Set("Authorization", secretToken) - - var nextMiddlewareRan bool - middleware.Middleware()(req, func(req *http.Request) (*http.Response, error) { - nextMiddlewareRan = true - - // The request body should still be fully readable after the middleware runs - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - require.Equal(t, bodyContent, string(body)) - - // The request sent down through middleware shouldn't be mutated. - require.Equal(t, secretToken, req.Header.Get("Authorization")) - - return &http.Response{}, nil - }) - - require.True(t, nextMiddlewareRan) - require.Contains(t, logBuf.String(), "Authorization: "+redactedPlaceholder) - }) -} diff --git a/internal/jsonview/explorer.go b/internal/jsonview/explorer.go deleted file mode 100644 index 836bb2c..0000000 --- a/internal/jsonview/explorer.go +++ /dev/null @@ -1,807 +0,0 @@ -package jsonview - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "math" - "os" - "strings" - - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/table" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/muesli/reflow/wordwrap" - "github.com/tidwall/gjson" -) - -const ( - // UI layout constants - borderPadding = 2 - heightOffset = 5 - tableMinHeight = 2 - titlePaddingLeft = 2 - titlePaddingTop = 0 - footerPaddingLeft = 1 - - // Column width constants - defaultColumnWidth = 10 - keyColumnWidth = 3 - valueColumnWidth = 5 - - // String formatting constants - maxStringLength = 100 - maxPreviewLength = 24 - - arrayColor = lipgloss.Color("1") - stringColor = lipgloss.Color("5") - objectColor = lipgloss.Color("4") -) - -type keyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Back key.Binding - PrintValue key.Binding - Raw key.Binding - Quit key.Binding -} - -func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Quit, k.Up, k.Down, k.Back, k.Enter, k.PrintValue, k.Raw} -} - -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{k.ShortHelp()} -} - -var keys = keyMap{ - Up: key.NewBinding( - key.WithKeys("up", "k"), - key.WithHelp("↑/k", "up"), - ), - Down: key.NewBinding( - key.WithKeys("down", "j"), - key.WithHelp("↓/j", "down"), - ), - Back: key.NewBinding( - key.WithKeys("left", "h", "backspace"), - key.WithHelp("←/h", "go back"), - ), - Enter: key.NewBinding( - key.WithKeys("right", "l"), - key.WithHelp("→/l", "expand"), - ), - PrintValue: key.NewBinding( - key.WithKeys("p"), - key.WithHelp("p", "print and exit"), - ), - Raw: key.NewBinding( - key.WithKeys("r"), - key.WithHelp("r", "toggle raw JSON"), - ), - Quit: key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c", "enter"), - key.WithHelp("q/enter", "quit"), - ), -} - -var ( - titleStyle = lipgloss.NewStyle().Bold(true).PaddingLeft(titlePaddingLeft).PaddingTop(titlePaddingTop) - arrayStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(arrayColor) - stringStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(stringColor) - objectStyle = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(objectColor) - stringLiteralStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("2")) -) - -type JSONView interface { - GetPath() string - GetData() gjson.Result - Update(tea.Msg, bool) tea.Cmd - View() string - Resize(width, height int) -} - -type TableView struct { - width int - height int - path string - data gjson.Result - table table.Model - rowData []gjson.Result - iterator AnyIterator - isLoading bool - columns []table.Column -} - -func (tv *TableView) GetPath() string { return tv.path } -func (tv *TableView) GetData() gjson.Result { return tv.data } -func (tv *TableView) View() string { return tv.table.View() } - -func (tv *TableView) Update(msg tea.Msg, raw bool) tea.Cmd { - var cmd tea.Cmd - tv.table, cmd = tv.table.Update(msg) - - // Check if we need to load more data - if tv.iterator != nil && !tv.isLoading && tv.data.IsArray() { - cursor := tv.table.Cursor() - totalRows := len(tv.table.Rows()) - - // Load more when we're at the last row - if cursor == totalRows-1 { - tv.isLoading = true - return tv.loadMoreData(raw) - } - } - - return cmd -} - -func (tv *TableView) loadMoreData(raw bool) tea.Cmd { - return func() tea.Msg { - if tv.iterator == nil { - return nil - } - - if !tv.iterator.Next() { - tv.isLoading = false - return tv.iterator.Err() - } - - obj := tv.iterator.Current() - var result gjson.Result - if jsonBytes, err := json.Marshal(obj); err != nil { - return err - } else { - result = gjson.ParseBytes(jsonBytes) - } - - if !result.Exists() { - tv.isLoading = false - return nil - } - - // Add the new item to our data - tv.rowData = append(tv.rowData, result) - - // Add new row to the table - newRow := table.Row{formatValue(result, raw)} - - // For array of objects, we need to format according to columns - if len(tv.columns) > 1 && result.IsObject() { - newRow = make(table.Row, len(tv.columns)) - for i, col := range tv.columns { - newRow[i] = formatValue(result.Get(col.Title), raw) - } - } - - rows := tv.table.Rows() - rows = append(rows, newRow) - tv.table.SetRows(rows) - - // Resize columns to accommodate the new data - tv.Resize(tv.width, tv.height) - - tv.isLoading = false - return nil - } -} - -func (tv *TableView) Resize(width, height int) { - tv.width = width - tv.height = height - tv.updateColumnWidths(width) - tv.table.SetHeight(min(height-heightOffset, tableMinHeight+len(tv.table.Rows()))) -} - -func (tv *TableView) updateColumnWidths(width int) { - columns := tv.table.Columns() - widths := make([]int, len(columns)) - - // Calculate required widths from headers and content - for i, col := range columns { - widths[i] = lipgloss.Width(col.Title) - } - - for _, row := range tv.table.Rows() { - for i, cell := range row { - if i < len(widths) { - widths[i] = max(widths[i], lipgloss.Width(cell)) - } - } - } - - totalWidth := sum(widths) - available := width - borderPadding*len(columns) - - if totalWidth <= available { - for i, w := range widths { - columns[i].Width = w - } - return - } - - fairShare := float64(available) / float64(len(columns)) - shrinkable := 0.0 - - for _, w := range widths { - if float64(w) > fairShare { - shrinkable += float64(w) - fairShare - } - } - - if shrinkable > 0 { - excess := float64(totalWidth - available) - for i, w := range widths { - if float64(w) > fairShare { - reduction := (float64(w) - fairShare) * (excess / shrinkable) - widths[i] = int(math.Round(float64(w) - reduction)) - } - } - } - - for i, w := range widths { - columns[i].Width = w - } - - tv.table.SetColumns(columns) -} - -type TextView struct { - path string - data gjson.Result - viewport viewport.Model - ready bool -} - -func (tv *TextView) GetPath() string { return tv.path } -func (tv *TextView) GetData() gjson.Result { return tv.data } -func (tv *TextView) View() string { return tv.viewport.View() } - -func (tv *TextView) Update(msg tea.Msg, raw bool) tea.Cmd { - var cmd tea.Cmd - tv.viewport, cmd = tv.viewport.Update(msg) - return cmd -} - -func (tv *TextView) Resize(width, height int) { - h := height - heightOffset - if !tv.ready { - tv.viewport = viewport.New(width, h) - tv.viewport.SetContent(wordwrap.String(tv.data.String(), width)) - tv.ready = true - return - } - tv.viewport.Width = width - tv.viewport.Height = h -} - -type JSONViewer struct { - stack []JSONView - root string - width int - height int - rawMode bool - message string - help help.Model -} - -// ExploreJSON explores a single JSON value known ahead of time -func ExploreJSON(title string, json gjson.Result) error { - view, err := newView("", json, false) - if err != nil { - return err - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -type hasRawJSON interface { - RawJSON() string -} - -// ExploreJSONStream explores JSON data loaded incrementally via an iterator -func ExploreJSONStream[T any](title string, it Iterator[T]) error { - anyIt := genericToAnyIterator(it) - - preloadCount := 20 - if termHeight, _, err := term.GetSize(os.Stdout.Fd()); err == nil { - preloadCount = termHeight - } - - items := make([]any, 0, preloadCount) - for i := 0; i < preloadCount && anyIt.Next(); i++ { - items = append(items, anyIt.Current()) - } - - if err := anyIt.Err(); err != nil { - return err - } - - arrayJSONBytes, err := marshalItemsToJSONArray(items) - if err != nil { - return err - } - - arrayJSON := gjson.ParseBytes(arrayJSONBytes) - view, err := newTableView("", arrayJSON, false) - if err != nil { - return err - } - - // Set iterator if there might be more data - if len(items) == preloadCount { - view.iterator = anyIt - } - - viewer := &JSONViewer{stack: []JSONView{view}, root: title, rawMode: false, help: help.New()} - _, err = tea.NewProgram(viewer).Run() - if viewer.message != "" { - _, msgErr := fmt.Println("\n" + viewer.message) - err = errors.Join(err, msgErr) - } - return err -} - -func marshalItemsToJSONArray(items []any) ([]byte, error) { - var buf bytes.Buffer - buf.WriteByte('[') - - for i, item := range items { - if i > 0 { - buf.WriteByte(',') - } - if hasRaw, ok := item.(hasRawJSON); ok { - buf.WriteString(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return nil, err - } - buf.Write(jsonData) - } - } - - buf.WriteByte(']') - return buf.Bytes(), nil -} - -func (v *JSONViewer) current() JSONView { return v.stack[len(v.stack)-1] } -func (v *JSONViewer) Init() tea.Cmd { return nil } - -func (v *JSONViewer) resize(width, height int) { - v.width, v.height = width, height - v.help.Width = width - for i := range v.stack { - v.stack[i].Resize(width, height) - } -} - -func (v *JSONViewer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - v.resize(msg.Width-borderPadding, msg.Height) - return v, nil - case tea.KeyMsg: - switch { - case key.Matches(msg, keys.Quit): - return v, tea.Quit - case key.Matches(msg, keys.Enter): - return v.navigateForward() - case key.Matches(msg, keys.Back): - return v.navigateBack() - case key.Matches(msg, keys.Raw): - return v.toggleRaw() - case key.Matches(msg, keys.PrintValue): - v.message = v.getSelectedContent() - return v, tea.Quit - } - } - - return v, v.current().Update(msg, v.rawMode) -} - -func (v *JSONViewer) getSelectedContent() string { - tableView, ok := v.current().(*TableView) - if !ok { - return v.current().GetData().Raw - } - - selected := tableView.rowData[tableView.table.Cursor()] - if selected.Type == gjson.String { - return selected.String() - } - return selected.Raw -} - -func (v *JSONViewer) navigateForward() (tea.Model, tea.Cmd) { - tableView, ok := v.current().(*TableView) - if !ok { - return v, nil - } - - if len(tableView.rowData) < 1 { - return v, nil - } - - cursor := tableView.table.Cursor() - selected := tableView.rowData[cursor] - if !v.canNavigateInto(selected) { - return v, nil - } - - path := v.buildNavigationPath(tableView, cursor) - forwardView, err := newView(path, selected, v.rawMode) - if err != nil { - return v, nil - } - - v.stack = append(v.stack, forwardView) - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) buildNavigationPath(tableView *TableView, cursor int) string { - if tableView.data.IsArray() { - return fmt.Sprintf("%s[%d]", tableView.path, cursor) - } - key := tableView.data.Get("@keys").Array()[cursor].Str - return fmt.Sprintf("%s[%s]", tableView.path, quoteString(key)) -} - -func quoteString(s string) string { - // Replace backslashes and quotes with escaped versions - s = strings.ReplaceAll(s, "\\", "\\\\") - s = strings.ReplaceAll(s, "\"", "\\\"") - return stringLiteralStyle.Render("\"" + s + "\"") -} - -func (v *JSONViewer) canNavigateInto(data gjson.Result) bool { - switch { - case data.IsArray(): - return len(data.Array()) > 0 - case data.IsObject(): - return len(data.Map()) > 0 - case data.Type == gjson.String: - str := data.String() - return strings.Contains(str, "\n") || lipgloss.Width(str) >= maxStringLength - } - return false -} - -func (v *JSONViewer) navigateBack() (tea.Model, tea.Cmd) { - if len(v.stack) > 1 { - v.stack = v.stack[:len(v.stack)-1] - } - return v, nil -} - -func (v *JSONViewer) toggleRaw() (tea.Model, tea.Cmd) { - v.rawMode = !v.rawMode - - for i, view := range v.stack { - viewWithRaw, err := newView(view.GetPath(), view.GetData(), v.rawMode) - if err != nil { - return v, tea.Printf("Error: %s", err) - } - if newTV, ok := viewWithRaw.(*TableView); ok { - if tv, ok := view.(*TableView); ok && tv.iterator != nil { - newTV.iterator = tv.iterator - } - } - v.stack[i] = viewWithRaw - } - - v.resize(v.width, v.height) - return v, nil -} - -func (v *JSONViewer) View() string { - view := v.current() - title := v.buildTitle(view) - content := titleStyle.Render(title) - style := v.getStyleForData(view.GetData()) - content += "\n" + style.Render(view.View()) - content += "\n" + v.help.View(keys) - return content -} - -func (v *JSONViewer) buildTitle(view JSONView) string { - title := v.root - if len(view.GetPath()) > 0 { - title += " → " + view.GetPath() - } - if v.rawMode { - title += " (JSON)" - } - return title -} - -func (v *JSONViewer) getStyleForData(data gjson.Result) lipgloss.Style { - switch { - case data.Type == gjson.String: - return stringStyle - case data.IsArray(): - return arrayStyle - default: - return objectStyle - } -} - -func newView(path string, data gjson.Result, raw bool) (JSONView, error) { - if data.Type == gjson.String { - return newTextView(path, data) - } - return newTableView(path, data, raw) -} - -func newTextView(path string, data gjson.Result) (*TextView, error) { - if !data.Exists() || data.Type != gjson.String { - return nil, fmt.Errorf("invalid text JSON") - } - return &TextView{path: path, data: data}, nil -} - -func newTableView(path string, data gjson.Result, raw bool) (*TableView, error) { - if !data.Exists() || data.Type != gjson.JSON { - return nil, fmt.Errorf("invalid table JSON") - } - - switch { - case data.IsArray(): - array := data.Array() - if isArrayOfObjects(array) { - return newArrayOfObjectsTableView(path, data, array, raw), nil - } else { - return newArrayTableView(path, data, array, raw), nil - } - case data.IsObject(): - return newObjectTableView(path, data, raw), nil - default: - return nil, fmt.Errorf("unsupported JSON type") - } -} - -func newArrayTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Items", Width: defaultColumnWidth}} - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - rows = append(rows, table.Row{formatValue(item, raw)}) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func newArrayOfObjectsTableView(path string, data gjson.Result, array []gjson.Result, raw bool) *TableView { - // Collect unique keys - keySet := make(map[string]struct{}) - var columns []table.Column - - for _, item := range array { - for _, key := range item.Get("@keys").Array() { - if _, exists := keySet[key.Str]; !exists { - keySet[key.Str] = struct{}{} - title := key.Str - columns = append(columns, table.Column{Title: title, Width: defaultColumnWidth}) - } - } - } - - rows := make([]table.Row, 0, len(array)) - rowData := make([]gjson.Result, 0, len(array)) - - for _, item := range array { - row := make(table.Row, len(columns)) - for i, col := range columns { - row[i] = formatValue(item.Get(col.Title), raw) - } - rows = append(rows, row) - rowData = append(rowData, item) - } - - t := createTable(columns, rows, arrayColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func newObjectTableView(path string, data gjson.Result, raw bool) *TableView { - columns := []table.Column{{Title: "Object"}, {}} - - keys := data.Get("@keys").Array() - rows := make([]table.Row, 0, len(keys)) - rowData := make([]gjson.Result, 0, len(keys)) - - for _, key := range keys { - value := data.Get(key.Str) - title := key.Str - rows = append(rows, table.Row{title, formatValue(value, raw)}) - rowData = append(rowData, value) - } - - // Adjust column widths based on content - for _, row := range rows { - for i, cell := range row { - if i < len(columns) { - columns[i].Width = max(columns[i].Width, lipgloss.Width(cell)) - } - } - } - - t := createTable(columns, rows, objectColor) - return &TableView{ - path: path, - data: data, - table: t, - rowData: rowData, - columns: columns, - } -} - -func createTable(columns []table.Column, rows []table.Row, bgColor lipgloss.Color) table.Model { - t := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - - // Set common table styles - s := table.DefaultStyles() - s.Header = s.Header. - BorderStyle(lipgloss.NormalBorder()). - BorderForeground(lipgloss.Color("240")). - BorderBottom(true). - Bold(true) - s.Selected = s.Selected. - Foreground(lipgloss.Color("229")). - Background(bgColor). - Bold(false) - t.SetStyles(s) - - return t -} - -func formatValue(value gjson.Result, raw bool) string { - if raw { - return value.Get("@ugly").Raw - } - - switch { - case value.IsObject(): - return formatObject(value) - case value.IsArray(): - return formatArray(value) - case value.Type == gjson.String: - return value.Str - default: - return value.Raw - } -} - -func formatObject(value gjson.Result) string { - keys := value.Get("@keys").Array() - keyStrs := make([]string, len(keys)) - - for i, key := range keys { - val := value.Get(key.Str) - keyStrs[i] = formatObjectKey(key.Str, val) - } - - return "{" + strings.Join(keyStrs, ", ") + "}" -} - -func formatObjectKey(key string, val gjson.Result) string { - switch { - case val.IsObject(): - return key + ":{…}" - case val.IsArray(): - return key + ":[…]" - case val.Type == gjson.String: - str := val.Str - if lipgloss.Width(str) <= maxPreviewLength { - return fmt.Sprintf(`%s:"%s"`, key, str) - } - return fmt.Sprintf(`%s:"%s…"`, key, truncate.String(str, uint(maxPreviewLength))) - default: - return key + ":" + val.Raw - } -} - -func formatArray(value gjson.Result) string { - switch count := len(value.Array()); count { - case 0: - return "[]" - case 1: - return "[...1 item...]" - default: - return fmt.Sprintf("[...%d items...]", count) - } -} - -func isArrayOfObjects(array []gjson.Result) bool { - for _, item := range array { - if !item.IsObject() { - return false - } - } - return len(array) > 0 -} - -func sum(ints []int) int { - total := 0 - for _, n := range ints { - total += n - } - return total -} - -// An iterator over `any` values -type AnyIterator interface { - Next() bool - Err() error - Current() any -} - -// A generic iterator interface that is used by the `genericIterator` struct -// below to convert iterators over specific types to an AnyIterator -type Iterator[T any] interface { - Next() bool - Err() error - Current() T -} - -// genericIterator adapts a generic Iterator[T] to an AnyIterator. -type genericIterator[T any] struct { - iterator Iterator[T] - current any -} - -func (g *genericIterator[T]) Next() bool { - if !g.iterator.Next() { - return false - } - g.current = g.iterator.Current() - return true -} - -func (g *genericIterator[T]) Err() error { - return g.iterator.Err() -} - -func (g *genericIterator[T]) Current() any { - return g.current -} - -func genericToAnyIterator[T any](it Iterator[T]) AnyIterator { - return &genericIterator[T]{ - iterator: it, - } -} diff --git a/internal/jsonview/explorer_test.go b/internal/jsonview/explorer_test.go deleted file mode 100644 index 67ee730..0000000 --- a/internal/jsonview/explorer_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package jsonview - -import ( - "testing" - - "github.com/charmbracelet/bubbles/help" - "github.com/tidwall/gjson" - - "github.com/stretchr/testify/require" -) - -func TestNavigateForward_EmptyRowData(t *testing.T) { - t.Parallel() - - // An empty JSON array produces a TableView with no rows. - emptyArray := gjson.Parse("[]") - view, err := newTableView("", emptyArray, false) - require.NoError(t, err) - - viewer := &JSONViewer{ - stack: []JSONView{view}, - root: "test", - help: help.New(), - } - - // Should return without panicking despite the empty data set. - model, cmd := viewer.navigateForward() - require.Equal(t, model, viewer, "expected same viewer model returned") - require.Nil(t, cmd) - - // Stack should remain unchanged (no new view pushed). - require.Equal(t, 1, len(viewer.stack), "expected stack length 1, got %d", len(viewer.stack)) -} - -// rawJSONItem implements HasRawJSON, returning pre-built JSON. -type rawJSONItem struct { - raw string -} - -func (r rawJSONItem) RawJSON() string { return r.raw } - -func TestMarshalItemsToJSONArray_WithHasRawJSON(t *testing.T) { - t.Parallel() - - items := []any{ - rawJSONItem{raw: `{"id":1,"name":"alice"}`}, - rawJSONItem{raw: `{"id":2,"name":"bob"}`}, - } - - got, err := marshalItemsToJSONArray(items) - require.NoError(t, err) - require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) -} - -func TestMarshalItemsToJSONArray_WithoutHasRawJSON(t *testing.T) { - t.Parallel() - - items := []any{ - map[string]any{"id": 1, "name": "alice"}, - map[string]any{"id": 2, "name": "bob"}, - } - - got, err := marshalItemsToJSONArray(items) - require.NoError(t, err) - require.JSONEq(t, `[{"id":1,"name":"alice"},{"id":2,"name":"bob"}]`, string(got)) -} diff --git a/internal/jsonview/staticdisplay.go b/internal/jsonview/staticdisplay.go deleted file mode 100644 index 768ea34..0000000 --- a/internal/jsonview/staticdisplay.go +++ /dev/null @@ -1,135 +0,0 @@ -package jsonview - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/term" - "github.com/muesli/reflow/truncate" - "github.com/tidwall/gjson" -) - -const ( - tabWidth = 2 -) - -var ( - keyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("75")).Bold(false) - stringValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("113")) - numberValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("215")) - boolValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("207")) - nullValueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Italic(true) - bulletStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) - containerStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("63")). - Padding(0, 1) -) - -func formatJSON(json gjson.Result, width int) string { - if !json.Exists() { - return nullValueStyle.Render("Invalid JSON") - } - return formatResult(json, 0, width) -} - -func formatResult(result gjson.Result, indent, width int) string { - switch result.Type { - case gjson.String: - str := result.Str - if str == "" { - return nullValueStyle.Render("(empty)") - } - if lipgloss.Width(str) > width { - str = truncate.String(str, uint(width-1)) + "…" - } - return stringValueStyle.Render(str) - case gjson.Number: - return numberValueStyle.Render(result.Raw) - case gjson.True: - return boolValueStyle.Render("yes") - case gjson.False: - return boolValueStyle.Render("no") - case gjson.Null: - return nullValueStyle.Render("null") - case gjson.JSON: - if result.IsArray() { - return formatJSONArray(result, indent, width) - } - return formatJSONObject(result, indent, width) - default: - return stringValueStyle.Render(result.String()) - } -} - -func isSingleLine(result gjson.Result, indent int) bool { - return !(result.IsObject() || result.IsArray()) -} - -func formatJSONArray(result gjson.Result, indent, width int) string { - items := result.Array() - if len(items) == 0 { - return nullValueStyle.Render(" (none)") - } - - numberWidth := lipgloss.Width(fmt.Sprintf("%d. ", len(items))) - - var formattedItems []string - for i, item := range items { - number := fmt.Sprintf("%d.", i+1) - numbering := getIndent(indent) + bulletStyle.Render(number) - - // If the item will be a one-liner, put it inline after the numbering, - // otherwise it starts with a newline and goes below the numbering. - itemWidth := width - if isSingleLine(item, indent+1) { - // Add right-padding: - numbering += strings.Repeat(" ", numberWidth-lipgloss.Width(number)) - itemWidth = width - lipgloss.Width(numbering) - } - value := formatResult(item, indent+1, itemWidth) - formattedItems = append(formattedItems, numbering+value) - } - return "\n" + strings.Join(formattedItems, "\n") -} - -func formatJSONObject(result gjson.Result, indent, width int) string { - keys := result.Get("@keys").Array() - if len(keys) == 0 { - return nullValueStyle.Render("(empty)") - } - - var items []string - for _, key := range keys { - value := result.Get(key.String()) - keyStr := getIndent(indent) + keyStyle.Render(key.String()+":") - // If item will be a one-liner, put it inline after the key, otherwise - // it starts with a newline and goes below the key. - itemWidth := width - if isSingleLine(value, indent+1) { - keyStr += " " - itemWidth = width - lipgloss.Width(keyStr) - } - formattedValue := formatResult(value, indent+1, itemWidth) - items = append(items, keyStr+formattedValue) - } - - return "\n" + strings.Join(items, "\n") -} - -func getIndent(indent int) string { - return strings.Repeat(" ", indent*tabWidth) -} - -func RenderJSON(title string, json gjson.Result) string { - width, _, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - width = 80 - } - width -= containerStyle.GetBorderLeftSize() + containerStyle.GetBorderRightSize() + - containerStyle.GetPaddingLeft() + containerStyle.GetPaddingRight() - content := strings.TrimLeft(formatJSON(json, width), "\n") - return titleStyle.Render(title) + "\n" + containerStyle.Render(content) -} diff --git a/internal/mocktest/mocktest.go b/internal/mocktest/mocktest.go deleted file mode 100644 index e1c483e..0000000 --- a/internal/mocktest/mocktest.go +++ /dev/null @@ -1,101 +0,0 @@ -package mocktest - -import ( - "bytes" - "context" - "fmt" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -var mockServerURL *url.URL - -func init() { - mockServerURL, _ = url.Parse("http://localhost:4010") - if testURL := os.Getenv("TEST_API_BASE_URL"); testURL != "" { - if parsed, err := url.Parse(testURL); err == nil { - mockServerURL = parsed - } - } -} - -// OnlyMockServerDialer only allows network connections to the mock server -type OnlyMockServerDialer struct{} - -func (d *OnlyMockServerDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - if address == mockServerURL.Host { - return (&net.Dialer{}).DialContext(ctx, network, address) - } - - return nil, fmt.Errorf("BLOCKED: connection to %s not allowed (only allowed: %s)", address, mockServerURL.Host) -} - -func blockNetworkExceptMockServer() (http.RoundTripper, http.RoundTripper) { - restricted := &http.Transport{ - DialContext: (&OnlyMockServerDialer{}).DialContext, - } - - origClient, origDefault := http.DefaultClient.Transport, http.DefaultTransport - http.DefaultClient.Transport, http.DefaultTransport = restricted, restricted - return origClient, origDefault -} - -func restoreNetwork(origClient, origDefault http.RoundTripper) { - http.DefaultClient.Transport, http.DefaultTransport = origClient, origDefault -} - -// TestRunMockTestWithFlags runs a test against a mock server with the provided -// CLI args and ensures it succeeds -func TestRunMockTestWithFlags(t *testing.T, args ...string) { - TestRunMockTestWithPipeAndFlags(t, nil, args...) -} - -// TestRunMockTestWithPipeAndFlags runs a test against a mock server with the provided -// data piped over stdin and CLI args and ensures it succeeds -func TestRunMockTestWithPipeAndFlags(t *testing.T, pipeData []byte, args ...string) { - origClient, origDefault := blockNetworkExceptMockServer() - defer restoreNetwork(origClient, origDefault) - - // Check if mock server is running - conn, err := net.DialTimeout("tcp", mockServerURL.Host, 2*time.Second) - if err != nil { - require.Fail(t, "Mock server is not running on "+mockServerURL.Host+". Please start the mock server before running tests.") - } else { - conn.Close() - } - - // Get the path to the main command - _, filename, _, ok := runtime.Caller(0) - require.True(t, ok, "Could not get current file path") - dirPath := filepath.Dir(filename) - project := filepath.Join(dirPath, "..", "..", "cmd", "agentmail") - - args = append([]string{"run", project, "--base-url", mockServerURL.String()}, args...) - - t.Logf("Testing command: go run ./cmd/agentmail %s", strings.Join(args[2:], " ")) - - cmd := exec.Command("go", args...) - cmd.Stdin = bytes.NewReader(pipeData) - output, err := cmd.CombinedOutput() - assert.NoError(t, err, "Test failed\nError: %v\nOutput: %s", err, output) - - t.Logf("Test passed successfully\nOutput:\n%s", string(output)) -} - -func TestFile(t *testing.T, contents string) string { - tmpDir := t.TempDir() - filename := filepath.Join(tmpDir, "file.txt") - require.NoError(t, os.WriteFile(filename, []byte(contents), 0644)) - return filename -} diff --git a/internal/requestflag/innerflag.go b/internal/requestflag/innerflag.go deleted file mode 100644 index 528915f..0000000 --- a/internal/requestflag/innerflag.go +++ /dev/null @@ -1,289 +0,0 @@ -package requestflag - -import ( - "fmt" - "reflect" - "strings" - - "github.com/urfave/cli/v3" -) - -// InnerFlag[T] represents a CLI flag for the urfave/cli package that allows setting -// nested fields within other flags. For example, using `--foo.baz` will set the "baz" -// field on a parent flag named `--foo`. -type InnerFlag[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | - []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -] struct { - Name string // name of the flag - DefaultText string // default text of the flag for usage purposes - Usage string // usage string for help output - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value - - OuterFlag cli.Flag // The flag on which this inner flag will set values - InnerField string // The inner field which this flag will set - DataAliases []string // alternate names recognized in YAML values passed as the outer flag - - // OuterIsArrayOfObjects tells an untyped outer flag (Flag[any], used for nullable - // complex schemas) to seed its underlying value as []map[string]any rather than - // map[string]any before SetInnerField runs. The hint is ignored for typed outer - // flags whose zero value already carries a dispatchable reflect.Kind. - OuterIsArrayOfObjects bool -} - -// GetDataAliases returns the aliases recognized when parsing inner field keys from piped or flag YAML. -func (f *InnerFlag[T]) GetDataAliases() []string { - return f.DataAliases -} - -// GetInnerField returns the API field name that this inner flag sets on its outer flag's value. -// For example, the flag --parent.foo targeting a parameter whose OpenAPI property name is "foo" -// would return "foo". This is distinct from the flag's CLI name and from any DataAliases entries. -func (f *InnerFlag[T]) GetInnerField() string { - return f.InnerField -} - -type HasOuterFlag interface { - cli.Flag - SetOuterFlag(cli.Flag) - GetOuterFlag() cli.Flag - GetInnerField() string - GetDataAliases() []string -} - -func (f *InnerFlag[T]) SetOuterFlag(flag cli.Flag) { - f.OuterFlag = flag -} - -func (f *InnerFlag[T]) GetOuterFlag() cli.Flag { - return f.OuterFlag -} - -// Implementation of the cli.Flag interface -var _ cli.Flag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) PreParse() error { - return nil -} - -func (f *InnerFlag[T]) PostParse() error { - return nil -} - -func (f *InnerFlag[T]) Set(name string, rawVal string) error { - if parsedValue, err := parseCLIArg[T](rawVal); err != nil { - return err - } else { - if f.Validator != nil { - if err := f.Validator(parsedValue); err != nil { - return err - } - } - - if seeder, ok := f.OuterFlag.(InnerFieldSeeder); ok { - seeder.SeedInnerCollection(f.OuterIsArrayOfObjects) - } - - if settableInnerField, ok := f.OuterFlag.(SettableInnerField); ok { - settableInnerField.SetInnerField(f.InnerField, parsedValue) - } else { - return fmt.Errorf("Cannot set inner field on %v", f.OuterFlag) - } - return nil - } -} - -func (f *InnerFlag[T]) Get() any { - var zeroValue T - return zeroValue -} - -func (f *InnerFlag[T]) String() string { - return cli.FlagStringer(f) -} - -func (f *InnerFlag[T]) IsSet() bool { - return false -} - -func (f *InnerFlag[T]) Names() []string { - return cli.FlagNames(f.Name, f.Aliases) -} - -// Implementation for the cli.DocGenerationFlag interface -var _ cli.DocGenerationFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) TakesValue() bool { - var t T - return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool -} - -func (f *InnerFlag[T]) GetUsage() string { - return f.Usage -} - -func (f *InnerFlag[T]) GetValue() string { - return "" -} - -func (f *InnerFlag[T]) GetDefaultText() string { - return f.DefaultText -} - -func (f *InnerFlag[T]) GetEnvVars() []string { - return nil -} - -func (f *InnerFlag[T]) IsDefaultVisible() bool { - return false -} - -func (f *InnerFlag[T]) TypeName() string { - var zeroValue T - ty := reflect.TypeOf(zeroValue) - if ty == nil { - return "" - } - if ty.Kind() == reflect.Pointer { - ty = ty.Elem() - } - - // Get base type name with special handling for built-in types - getTypeName := func(t reflect.Type) string { - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "int" - case reflect.Float32, reflect.Float64: - return "float" - case reflect.Bool: - return "boolean" - case reflect.String: - switch t.Name() { - case "DateTimeValue": - return "datetime" - case "DateValue": - return "date" - case "TimeValue": - return "time" - default: - return "string" - } - default: - if t.Name() == "" { - return "any" - } - return strings.ToLower(t.Name()) - } - } - - switch ty.Kind() { - case reflect.Slice: - elemType := ty.Elem() - return getTypeName(elemType) - case reflect.Map: - keyType := ty.Key() - valueType := ty.Elem() - return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) - default: - return getTypeName(ty) - } -} - -// Implementation for the cli.DocGenerationMultiValueFlag interface -var _ cli.DocGenerationMultiValueFlag = (*InnerFlag[any])(nil) // Type assertion to ensure interface compliance - -func (f *InnerFlag[T]) IsMultiValueFlag() bool { - return false -} - -func (f *InnerFlag[T]) IsBoolFlag() bool { - var zeroValue T - _, isBool := any(zeroValue).(bool) - return isBool -} - -// WithInnerFlags takes a command and a map of flag names to inner flags, -// and returns a modified command with the appropriate inner flags set. -func WithInnerFlags(cmd cli.Command, innerFlagMap map[string][]HasOuterFlag) cli.Command { - if len(innerFlagMap) == 0 { - return cmd - } - - // If any keys are unused by the end, we know that they were not valid - unusedInnerFlagKeys := make(map[string]struct{}) - for name := range innerFlagMap { - unusedInnerFlagKeys[name] = struct{}{} - } - - updatedFlags := make([]cli.Flag, 0, len(cmd.Flags)) - for _, flag := range cmd.Flags { - updatedFlags = append(updatedFlags, flag) - for _, name := range flag.Names() { - // Check if this flag has inner flags in our map - innerFlags, hasInnerFlags := innerFlagMap[name] - if !hasInnerFlags { - continue - } - - // Mark this inner flag key as used - delete(unusedInnerFlagKeys, name) - - for _, innerFlag := range innerFlags { - innerFlag.SetOuterFlag(flag) - updatedFlags = append(updatedFlags, innerFlag) - } - } - } - - // If there are inner flags that don't correspond to any valid outer flag - // names, then panic because the user probably made a typo or forgot to - // delete inner flags that correspond to missing outer flags. - if len(unusedInnerFlagKeys) > 0 { - unusedKeys := make([]string, 0, len(unusedInnerFlagKeys)) - for key := range unusedInnerFlagKeys { - unusedKeys = append(unusedKeys, key) - } - panic(fmt.Sprintf("Missing outer flags to use with inner flags: %v", unusedKeys)) - } - - result := cmd - result.Flags = updatedFlags - return result -} - -// Helper function to verify that all inner flags have an outer flag set and -// follow the --foo.baz prefix format -func CheckInnerFlags(cmd cli.Command) error { - var errors []string - for _, flag := range cmd.Flags { - if innerFlag, ok := flag.(HasOuterFlag); ok { - outerFlag := innerFlag.GetOuterFlag() - if outerFlag == nil { - errors = append(errors, fmt.Sprintf("inner flag %s is missing an outer flag", flag.Names())) - continue - } - - innerFlagName := flag.Names()[0] - valid := false - for _, outerName := range outerFlag.Names() { - if strings.HasPrefix(innerFlagName, outerName+".") { - valid = true - break - } - } - - if !valid { - errors = append(errors, fmt.Sprintf("inner flag %s must start with one of its outer flag's names followed by a dot", innerFlagName)) - } - } - } - - if len(errors) > 0 { - return fmt.Errorf("%s", strings.Join(errors, "; ")) - } - return nil -} diff --git a/internal/requestflag/innerflag_test.go b/internal/requestflag/innerflag_test.go deleted file mode 100644 index 133e8b4..0000000 --- a/internal/requestflag/innerflag_test.go +++ /dev/null @@ -1,347 +0,0 @@ -package requestflag - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestInnerFlagSet(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - flagType string - inputVal string - expected any - expectErr bool - }{ - {"string", "string", "hello", "hello", false}, - {"int64", "int64", "42", int64(42), false}, - {"float64", "float64", "3.14", float64(3.14), false}, - {"bool", "bool", "true", true, false}, - {"invalid int", "int64", "not-a-number", nil, true}, - {"invalid float", "float64", "not-a-float", nil, true}, - {"invalid bool", "bool", "not-a-bool", nil, true}, - {"yaml map", "map", "key: value", map[string]any{"key": "value"}, false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{ - Name: "test-flag", - } - - var innerFlag cli.Flag - switch tt.flagType { - case "string": - innerFlag = &InnerFlag[string]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "int64": - innerFlag = &InnerFlag[int64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "float64": - innerFlag = &InnerFlag[float64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "bool": - innerFlag = &InnerFlag[bool]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - case "map": - innerFlag = &InnerFlag[map[string]any]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - } - } - - err := innerFlag.Set(innerFlag.Names()[0], tt.inputVal) - - if tt.expectErr { - assert.Error(t, err) - return - } - - assert.NoError(t, err) - actual, ok := outerFlag.Get().(map[string]any)["test_field"] - assert.True(t, ok, "Field 'test_field' should exist in the map") - assert.Equal(t, tt.expected, actual, "Expected %v (%T), got %v (%T)", tt.expected, tt.expected, actual, actual) - }) - } -} - -func TestInnerFlagValidator(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "test-flag"} - - innerFlag := &InnerFlag[int64]{ - Name: "test-flag.test-field", - OuterFlag: outerFlag, - InnerField: "test_field", - Validator: func(val int64) error { - if val < 0 { - return cli.Exit("Value must be non-negative", 1) - } - return nil - }, - } - - // Valid case - err := innerFlag.Set(innerFlag.Name, "42") - assert.NoError(t, err, "Expected no error for valid value, got: %v", err) - - // Should trigger validator error - err = innerFlag.Set(innerFlag.Name, "-5") - assert.Error(t, err, "Expected error for invalid value, got none") -} - -func TestWithInnerFlags(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[string]{ - Name: "outer.baz", - InnerField: "baz", - } - - cmd := WithInnerFlags(cli.Command{ - Name: "test-command", - Flags: []cli.Flag{outerFlag}, - }, map[string][]HasOuterFlag{ - "outer": {innerFlag}, - }) - - // Verify that the command now has both the original flag and inner flag - assert.Len(t, cmd.Flags, 2, "Expected 2 flags, got %d", len(cmd.Flags)) - assert.Equal(t, outerFlag, cmd.Flags[0], "First flag should be outerFlag") - assert.Equal(t, innerFlag, cmd.Flags[1], "Second flag should be innerFlag") - assert.Same(t, outerFlag, innerFlag.OuterFlag, "innerFlag.OuterFlag should point to outerFlag") -} - -func TestInnerFlagTypeNames(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - flag cli.DocGenerationFlag - expected string - }{ - {"string", &InnerFlag[string]{}, "string"}, - {"int64", &InnerFlag[int64]{}, "int"}, - {"float64", &InnerFlag[float64]{}, "float"}, - {"bool", &InnerFlag[bool]{}, "boolean"}, - {"string slice", &InnerFlag[[]string]{}, "string"}, - {"date", &InnerFlag[DateValue]{}, "date"}, - {"datetime", &InnerFlag[DateTimeValue]{}, "datetime"}, - {"time", &InnerFlag[TimeValue]{}, "time"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - typeName := tt.flag.TypeName() - assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) - }) - } -} - -func TestInnerYamlHandling(t *testing.T) { - t.Parallel() - - // Test with map value - t.Run("Parse YAML to map", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[map[string]any]{ - Name: "outer.baz", - OuterFlag: outerFlag, - InnerField: "baz", - } - - err := innerFlag.Set(innerFlag.Name, "{name: test, value: 42}") - assert.NoError(t, err) - - // Retrieve and check the parsed YAML map - result, ok := outerFlag.Get().(map[string]any) - assert.True(t, ok, "Expected map[string]any from outerFlag.Get()") - yamlField, ok := result["baz"].(map[string]any) - assert.True(t, ok, "Expected map[string]any, got %T", result["baz"]) - val := yamlField - - if ok { - assert.Equal(t, map[string]any{"name": "test", "value": uint64(42)}, val) - } - }) - - // Test with invalid YAML - t.Run("Parse invalid YAML", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - innerFlag := &InnerFlag[map[string]any]{ - Name: "outer.baz", - OuterFlag: outerFlag, - InnerField: "baz", - } - - invalidYaml := `[not closed` - err := innerFlag.Set(innerFlag.Name, invalidYaml) - assert.Error(t, err) - }) - - // Test setting inner flags on a map multiple times - t.Run("Set inner flags on map multiple times", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - - // Set first inner flag - firstInnerFlag := &InnerFlag[string]{ - Name: "outer.first-flag", - OuterFlag: outerFlag, - InnerField: "first_field", - } - - err := firstInnerFlag.Set(firstInnerFlag.Name, "first-value") - assert.NoError(t, err) - - // Set second inner flag - secondInnerFlag := &InnerFlag[int64]{ - Name: "outer.second-flag", - OuterFlag: outerFlag, - InnerField: "second_field", - } - - err = secondInnerFlag.Set(secondInnerFlag.Name, "42") - assert.NoError(t, err) - - // Verify both fields are set correctly - result := outerFlag.Get().(map[string]any) - assert.Equal(t, map[string]any{"first_field": "first-value", "second_field": int64(42)}, result) - }) - - // Test setting YAML and then an inner flag - t.Run("Set YAML and then inner flag", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[map[string]any]{Name: "outer"} - - // First set the outer flag with YAML - err := outerFlag.Set(outerFlag.Name, `{existing: value, another: field}`) - assert.NoError(t, err) - - // Then set an inner flag - innerFlag := &InnerFlag[string]{ - Name: "outer.inner-flag", - OuterFlag: outerFlag, - InnerField: "new_field", - } - - err = innerFlag.Set(innerFlag.Name, "inner-value") - assert.NoError(t, err) - - // Verify both the YAML content and inner flag value - result := outerFlag.Get().(map[string]any) - assert.Equal(t, map[string]any{ - "existing": "value", - "another": "field", - "new_field": "inner-value", - }, result) - }) -} - -func TestInnerFlagWithSliceType(t *testing.T) { - t.Parallel() - - t.Run("Setting inner flags on slice of maps", func(t *testing.T) { - t.Parallel() - - outerFlag := &Flag[[]map[string]any]{Name: "outer"} - - // Set first inner flag (should create first item) - firstInnerFlag := &InnerFlag[string]{ - Name: "outer.name-flag", - OuterFlag: outerFlag, - InnerField: "name", - } - - err := firstInnerFlag.Set(firstInnerFlag.Name, "item1") - assert.NoError(t, err) - - // Set second inner flag (should modify first item) - secondInnerFlag := &InnerFlag[int64]{ - Name: "outer.count-flag", - OuterFlag: outerFlag, - InnerField: "count", - } - - err = secondInnerFlag.Set(secondInnerFlag.Name, "42") - assert.NoError(t, err) - - // Set name flag again (should create second item) - err = firstInnerFlag.Set(firstInnerFlag.Name, "item2") - assert.NoError(t, err) - - // Verify the slice has two items with correct values - result := outerFlag.Get().([]map[string]any) - - assert.Equal(t, []map[string]any{ - {"name": "item1", "count": int64(42)}, - {"name": "item2"}, - }, result) - assert.Nil(t, result[1]["count"], "Second item should not have count field") - }) - - t.Run("Appending to existing slice", func(t *testing.T) { - t.Parallel() - - // Initialize with existing items - outerFlag := &Flag[[]map[string]any]{Name: "outer"} - err := outerFlag.Set(outerFlag.Name, `{name: initial}`) - assert.NoError(t, err) - - // Set inner flag to modify existing item - modifyFlag := &InnerFlag[string]{ - Name: "outer.value-flag", - OuterFlag: outerFlag, - InnerField: "value", - } - - err = modifyFlag.Set(modifyFlag.Name, "updated") - assert.NoError(t, err) - - // Set inner flag to create new item - newItemFlag := &InnerFlag[string]{ - Name: "outer.name-flag", - OuterFlag: outerFlag, - InnerField: "name", - } - - err = newItemFlag.Set(newItemFlag.Name, "second") - assert.NoError(t, err) - - // Verify both items - result := outerFlag.Get().([]map[string]any) - assert.Equal(t, []map[string]any{ - {"name": "initial", "value": "updated"}, - {"name": "second"}, - }, result) - }) -} diff --git a/internal/requestflag/requestflag.go b/internal/requestflag/requestflag.go deleted file mode 100644 index 77c4f1f..0000000 --- a/internal/requestflag/requestflag.go +++ /dev/null @@ -1,992 +0,0 @@ -package requestflag - -import ( - "encoding/json" - "fmt" - "reflect" - "strconv" - "strings" - "time" - "unicode" - - "github.com/goccy/go-yaml" - "github.com/urfave/cli/v3" -) - -// formatForFlagSet converts a Go value parsed from YAML/JSON stdin data into a string -// that flag.Set (and thus parseCLIArg) can parse correctly for each flag type. -// Strings are returned as-is (parseCLIArg[string] assigns the raw value directly, so -// JSON-quoting must be avoided). Scalars use %v. Complex types (maps, slices) are -// JSON-encoded, which the yaml.Unmarshal default branch in parseCLIArg can parse. -func formatForFlagSet(val any) (string, error) { - switch v := val.(type) { - case string: - return v, nil - case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: - return fmt.Sprintf("%v", val), nil - default: - b, err := json.Marshal(val) - if err != nil { - return "", fmt.Errorf("cannot format value %T for flag.Set: %w", val, err) - } - return string(b), nil - } -} - -// Flag [T] is a generic flag base which can be used to implement the most -// common interfaces used by urfave/cli. Additionally, it allows specifying -// where in an HTTP request the flag values should be placed (e.g. query, body, etc.). -// -// Pointer-to-primitive type parameters (e.g. *string) are used for flags whose underlying -// schema is nullable. They give flags a tri-state: unset (excluded from the request), -// set to the literal "null" (nil pointer → JSON null), or set to a value (*v → JSON value). -type Flag[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | - []float64 | []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | - string | float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -] struct { - Name string // name of the flag - Category string // category of the flag, if any - DefaultText string // default text of the flag for usage purposes - HideDefault bool // whether to hide the default value in output - Usage string // usage string for help output - Sources cli.ValueSourceChain // sources to load flag value from - Required bool // whether the flag is required or not - Hidden bool // whether to hide the flag in help output - Default T // default value for this flag if not set by from any source - Aliases []string // aliases that are allowed for this flag - Validator func(T) error // custom function to validate this flag value - - QueryPath string // location in the request query string to put this flag's value - HeaderPath string // location in the request header to put this flag's value - BodyPath string // location in the request body to put this flag's value - BodyRoot bool // if true, then use this value as the entire request body - PathParam string // name of the URL path parameter this flag's value maps to - - // Const, when true, marks this flag as a constant. The flag's Default value is used as the fixed value - // and always included in the request (IsSet returns true). The user can still see and override the flag, - // but isn't required to provide it. This is used for single-value enums and `x-stainless-const` - // parameters. - Const bool - - // FileInput, when true, indicates that the flag value is always treated as a file path. The file is read - // automatically without requiring the "@" prefix. This is used for parameters with `type: string, format: - // binary` in the OpenAPI spec. - FileInput bool - - // DataAliases is a list of alternate names for this parameter recognized when parsing piped YAML/JSON - // input. Values keyed by any alias are translated to the canonical API name before being sent. - DataAliases []string - - // unexported fields for internal use - count int // number of times the flag has been set - hasBeenSet bool // whether the flag has been set from env or file - applied bool // whether the flag has been applied to a flag set already - value cli.Value // value representing this flag's value -} - -// Type assertions to verify we implement the relevant urfave/cli interfaces -var _ cli.CategorizableFlag = (*Flag[any])(nil) - -// InRequest interface for flags that should be included in HTTP requests -type InRequest interface { - GetQueryPath() string - GetHeaderPath() string - GetBodyPath() string - GetPathParam() string - IsBodyRoot() bool - IsFileInput() bool - GetDataAliases() []string -} - -func (f Flag[T]) GetQueryPath() string { - return f.QueryPath -} - -func (f Flag[T]) GetHeaderPath() string { - return f.HeaderPath -} - -func (f Flag[T]) GetBodyPath() string { - return f.BodyPath -} - -func (f Flag[T]) GetPathParam() string { - return f.PathParam -} - -func (f Flag[T]) IsBodyRoot() bool { - return f.BodyRoot -} - -func (f Flag[T]) IsFileInput() bool { - return f.FileInput -} - -func (f Flag[T]) GetDataAliases() []string { - return f.DataAliases -} - -// The values that will be sent in different parts of a request. -type RequestContents struct { - Queries map[string]any - Headers map[string]any - Body any -} - -// ApplyStdinDataToFlags sets flag values from a parsed stdin data map for flags that have not already been -// set via the command line. This allows piped YAML/JSON data to satisfy path, query, and header parameters. -// Body parameters are excluded: they are already handled by the maps.Copy merge in flagOptions. -// For each unset flag, if the parsed data map contains a key matching the flag's QueryPath, HeaderPath, or -// PathParam (or any of its DataAliases), the flag is set to that value via flag.Set. -// -// Inner flags (those with an outer flag) are also handled: if the outer flag's body path key exists in the -// data map and contains a nested map with a key matching the inner flag's field (or aliases), the inner -// flag is set from that nested value. -func ApplyStdinDataToFlags(cmd *cli.Command, data map[string]any) error { - for _, flag := range cmd.Flags { - if flag.IsSet() { - continue - } - - // Handle inner flags: look for their value nested under the outer flag's body path. - if inner, ok := flag.(HasOuterFlag); ok { - outer, outerOk := inner.GetOuterFlag().(InRequest) - if !outerOk || outer.GetBodyPath() == "" { - continue - } - nested, ok := data[outer.GetBodyPath()].(map[string]any) - if !ok { - continue - } - innerField := inner.GetInnerField() - val, found := nested[innerField] - if !found { - for _, alias := range inner.GetDataAliases() { - if alias != "" && alias != innerField { - if v, ok := nested[alias]; ok { - val, found = v, true - break - } - } - } - } - if !found { - continue - } - setVal, err := formatForFlagSet(val) - if err != nil { - return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) - } - if err := flag.Set(flag.Names()[0], setVal); err != nil { - return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) - } - continue - } - - inReq, ok := flag.(InRequest) - if !ok { - continue - } - - // Try each request location in turn, checking the canonical path key and all aliases. - // Body params are excluded: they are already handled by the maps.Copy merge in flagOptions. - for _, path := range []string{inReq.GetQueryPath(), inReq.GetHeaderPath(), inReq.GetPathParam()} { - if path == "" { - continue - } - var val any - var found bool - for _, key := range append([]string{path}, inReq.GetDataAliases()...) { - if v, ok := data[key]; ok { - val, found = v, true - break - } - } - if !found { - continue - } - setVal, err := formatForFlagSet(val) - if err != nil { - return fmt.Errorf("cannot format piped value for flag %q: %w", flag.Names()[0], err) - } - if err := flag.Set(flag.Names()[0], setVal); err != nil { - return fmt.Errorf("cannot set flag %q from piped data: %w", flag.Names()[0], err) - } - break - } - } - return nil -} - -func ExtractRequestContents(cmd *cli.Command) RequestContents { - bodyMap := make(map[string]any) - res := RequestContents{ - Queries: make(map[string]any), - Headers: make(map[string]any), - Body: bodyMap, - } - - for _, flag := range cmd.Flags { - if !flag.IsSet() { - continue - } - - value := flag.Get() - if toSend, ok := flag.(InRequest); ok { - if queryPath := toSend.GetQueryPath(); queryPath != "" { - res.Queries[queryPath] = value - } - if headerPath := toSend.GetHeaderPath(); headerPath != "" { - res.Headers[headerPath] = value - } - if toSend.IsBodyRoot() { - res.Body = value - } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { - bodyMap[bodyPath] = value - } - } - } - return res -} - -func GetMissingRequiredFlags(cmd *cli.Command, body any) []cli.Flag { - missing := []cli.Flag{} - for _, flag := range cmd.Flags { - if flag.IsSet() { - continue - } - - if required, ok := flag.(cli.RequiredFlag); ok && required.IsRequired() { - missing = append(missing, flag) - continue - } - - if r, ok := flag.(RequiredFlagOrStdin); !ok || !r.IsRequiredAsFlagOrStdin() { - continue - } - - if toSend, ok := flag.(InRequest); ok { - if toSend.IsBodyRoot() { - if body != nil { - continue - } - } else if bodyPath := toSend.GetBodyPath(); bodyPath != "" { - if bodyMap, ok := body.(map[string]any); ok { - if _, found := bodyMap[bodyPath]; found { - continue - } - } - } - } - missing = append(missing, flag) - } - return missing -} - -// Implementation of the cli.Flag interface -var _ cli.Flag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) PreParse() error { - newVal := f.Default - f.value = &cliValue[T]{newVal} - - // Validate the given default or values set from external sources as well - if f.Validator != nil { - if err := f.Validator(f.value.Get().(T)); err != nil { - return err - } - } - f.applied = true - return nil -} - -func (f *Flag[T]) PostParse() error { - if !f.hasBeenSet { - if val, source, found := f.Sources.LookupWithSource(); found { - if val != "" || reflect.TypeOf(f.value).Kind() == reflect.String { - if err := f.Set(f.Name, val); err != nil { - return fmt.Errorf( - "could not parse %[1]q as %[2]T value from %[3]s for flag %[4]s: %[5]s", - val, f.value, source, f.Name, err, - ) - } - } else if val == "" && reflect.TypeOf(f.value).Kind() == reflect.Bool { - _ = f.Set(f.Name, "false") - } - - f.hasBeenSet = true - } - } - return nil -} - -func (f *Flag[T]) Set(name string, val string) error { - // Initialize flag if needed - if !f.applied { - if err := f.PreParse(); err != nil { - return err - } - f.applied = true - } - - f.count++ - - // If this is the first time setting a slice type, reset it to empty - // to avoid appending to the default value - if f.count == 1 && f.value != nil { - typ := reflect.TypeOf(f.Default) - if typ != nil && typ.Kind() == reflect.Slice { - // Create a new empty slice of the same type and set it - emptySlice := reflect.MakeSlice(typ, 0, 0).Interface() - f.value = &cliValue[T]{emptySlice.(T)} - } - } - - if err := f.value.Set(val); err != nil { - return err - } - - f.hasBeenSet = true - - if f.Validator != nil { - if err := f.Validator(f.value.Get().(T)); err != nil { - return err - } - } - return nil -} - -func (f *Flag[T]) Get() any { - if f.value != nil { - return f.value.Get() - } - return f.Default -} - -func (f *Flag[T]) String() string { - return cli.FlagStringer(f) -} - -func (f *Flag[T]) IsSet() bool { - return f.hasBeenSet || f.Const -} - -func (f *Flag[T]) Names() []string { - return cli.FlagNames(f.Name, f.Aliases) -} - -// Implementation for the cli.VisibleFlag interface -var _ cli.VisibleFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsVisible() bool { - return !f.Hidden -} - -func (f *Flag[T]) GetCategory() string { - return f.Category -} - -func (f *Flag[T]) SetCategory(c string) { - f.Category = c -} - -// Implementation for the cli.RequiredFlag interface -var _ cli.RequiredFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsRequired() bool { - // Const flags are always auto-set, so never required from the user. - if f.Const { - return false - } - // Intentionally don't use `f.Required`, because request flags may be passed - // over stdin as well as by flag. - if f.BodyPath != "" || f.BodyRoot || f.PathParam != "" || f.QueryPath != "" || f.HeaderPath != "" { - return false - } - return f.Required -} - -type RequiredFlagOrStdin interface { - IsRequiredAsFlagOrStdin() bool -} - -func (f *Flag[T]) IsRequiredAsFlagOrStdin() bool { - // Const flags are always auto-set, so never required from the user. - if f.Const { - return false - } - return f.Required -} - -// Implementation for the cli.DocGenerationFlag interface -var _ cli.DocGenerationFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) TakesValue() bool { - var t T - return reflect.TypeOf(t) == nil || reflect.TypeOf(t).Kind() != reflect.Bool -} - -func (f *Flag[T]) GetUsage() string { - return f.Usage -} - -func (f *Flag[T]) GetValue() string { - if f.value == nil { - return "" - } - return f.value.String() -} - -func (f *Flag[T]) GetDefaultText() string { - return f.DefaultText -} - -// GetEnvVars returns the env vars for this flag -func (f *Flag[T]) GetEnvVars() []string { - return f.Sources.EnvKeys() -} - -func (f *Flag[T]) IsDefaultVisible() bool { - return !f.HideDefault -} - -func (f *Flag[T]) TypeName() string { - ty := reflect.TypeOf(f.Default) - if ty == nil { - return "" - } - // Deref pointer-typed flags so --help surfaces the pointee kind (e.g. "string"), not - // Go's pointer syntax. - if ty.Kind() == reflect.Pointer { - ty = ty.Elem() - } - - // Get base type name with special handling for built-in types - getTypeName := func(t reflect.Type) string { - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return "int" - case reflect.Float32, reflect.Float64: - return "float" - case reflect.Bool: - return "boolean" - case reflect.String: - switch t.Name() { - case "DateTimeValue": - return "datetime" - case "DateValue": - return "date" - case "TimeValue": - return "time" - default: - return "string" - } - default: - if t.Name() == "" { - return "any" - } - return strings.ToLower(t.Name()) - } - } - - switch ty.Kind() { - case reflect.Slice: - elemType := ty.Elem() - return getTypeName(elemType) - case reflect.Map: - keyType := ty.Key() - valueType := ty.Elem() - return fmt.Sprintf("%s=%s", getTypeName(keyType), getTypeName(valueType)) - default: - return getTypeName(ty) - } -} - -// Implementation for the cli.DocGenerationMultiValueFlag interface -var _ cli.DocGenerationMultiValueFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) IsMultiValueFlag() bool { - if reflect.TypeOf(f.Default) == nil { - return false - } - kind := reflect.TypeOf(f.Default).Kind() - return kind == reflect.Slice || kind == reflect.Map -} - -func (f *Flag[T]) IsBoolFlag() bool { - // Flag[*bool] is deliberately not treated as a bool flag — the pointer form needs an - // explicit value (`--foo true`, `--foo null`) to disambiguate the tri-state. - _, isBool := any(f.Default).(bool) - return isBool -} - -// Implementation for the cli.Countable interface -var _ cli.Countable = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f *Flag[T]) Count() int { - return f.count -} - -// Implementation for the cli.LocalFlag interface -var _ cli.LocalFlag = (*Flag[any])(nil) // Type assertion to ensure interface compliance - -func (f Flag[T]) IsLocal() bool { - // By default, all request flags are local, i.e. can be provided at any part of the CLI command. - return true -} - -// cliValue is a generic implementation of cli.Value for common types -type cliValue[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | - []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -] struct { - value T -} - -// Take an argument string for a single argument and convert it into a typed -// value for one of the supported CLI argument types -func parseCLIArg[ - T []any | []map[string]any | []DateTimeValue | []DateValue | []TimeValue | []string | []float64 | - []int64 | []bool | any | map[string]any | DateTimeValue | DateValue | TimeValue | string | - float64 | int64 | bool | - *string | *float64 | *int64 | *bool | *DateTimeValue | *DateValue | *TimeValue, -](value string) (T, error) { - var parsedValue any - var err error - - var empty T - - if value == "null" { - switch any(empty).(type) { - // Pointer-to-primitive: explicit nil gives the tri-state its "null" state - // (unset / null / value). Without this, numeric flags would fail to parse - // "null" and string flags would accept the literal word as a raw value. - case *string, *int64, *float64, *bool, *DateValue, *DateTimeValue, *TimeValue: - return empty, nil - // Maps marshal nil as JSON null natively; short-circuit avoids a YAML round-trip. - case map[string]any: - return empty, nil - } - } - - switch any(empty).(type) { - case string: - parsedValue = value - case int64: - parsedValue, err = strconv.ParseInt(value, 0, 64) - case float64: - parsedValue, err = strconv.ParseFloat(value, 64) - case bool: - parsedValue, err = strconv.ParseBool(value) - case DateTimeValue: - var dt DateTimeValue - err = (&dt).Parse(value) - if err == nil { - parsedValue = dt - } - - case DateValue: - var d DateValue - err = (&d).Parse(value) - if err == nil { - parsedValue = d - } - - case TimeValue: - var t TimeValue - err = (&t).Parse(value) - if err == nil { - parsedValue = t - } - - // Pointer-to-primitive flags reach here only when `value != "null"`; we parse the - // pointee type and return its address so JSON marshaling emits the underlying value. - case *string: - v := value - parsedValue = &v - case *int64: - var v int64 - v, err = strconv.ParseInt(value, 0, 64) - if err == nil { - parsedValue = &v - } - case *float64: - var v float64 - v, err = strconv.ParseFloat(value, 64) - if err == nil { - parsedValue = &v - } - case *bool: - var v bool - v, err = strconv.ParseBool(value) - if err == nil { - parsedValue = &v - } - case *DateTimeValue: - var dt DateTimeValue - err = (&dt).Parse(value) - if err == nil { - parsedValue = &dt - } - case *DateValue: - var d DateValue - err = (&d).Parse(value) - if err == nil { - parsedValue = &d - } - case *TimeValue: - var t TimeValue - err = (&t).Parse(value) - if err == nil { - parsedValue = &t - } - - default: - if strings.HasPrefix(value, "@") { - // File literals like @file.txt should work here - parsedValue = value - } else { - var yamlValue T - err = yaml.Unmarshal([]byte(value), &yamlValue) - if err == nil { - parsedValue = yamlValue - } else if allowAsLiteralString(value) { - parsedValue = value - } else { - parsedValue = nil - err = fmt.Errorf("failed to parse as YAML: %w", err) - } - } - } - - // Nil needs to be handled specially because unmarshalling a YAML `null` - // causes problems when doing type assertions. - if parsedValue == nil { - parsedValue = (*struct{})(nil) - } - - if err == nil { - if typedValue, ok := parsedValue.(T); ok { - return typedValue, nil - } else { - expectedType := reflect.TypeFor[T]() - err = fmt.Errorf("Couldn't convert %q (%v) to expected type %v", value, parsedValue, expectedType) - } - } - return empty, err - -} - -// Ptr returns a pointer to its argument. It is used to initialize `Default` on pointer-typed -// Flag values, since Go does not allow taking the address of a composite literal's element -// or of an untyped constant. -func Ptr[T any](v T) *T { - return &v -} - -// Assuming this string failed to parse as valid YAML, this function will -// return true for strings that can reasonably be interpreted as a string literal, -// like identifiers (`foo_bar`), UUIDs (`945b2f0c-8e89-487a-b02c-f851c69ea459`), -// base64 (`aGVsbG8=`), and qualified identifiers (`color.Red`). This should -// not include strings that look like mistyped YAML (e.g. `{key:`) -func allowAsLiteralString(s string) bool { - for _, c := range s { - if !unicode.IsLetter(c) && !unicode.IsDigit(c) && - c != '_' && c != '-' && c != '.' && c != '=' { - return false - } - } - return true -} - -// Parse the input string and set result as the cliValue's value -func (c *cliValue[T]) Set(value string) error { - valueType := reflect.TypeOf(c.value) - // When setting slice values, we append to the existing values - // e.g. --foo 10 --foo 20 --foo 30 => [10, 20, 30] - if valueType != nil && valueType.Kind() == reflect.Slice { - elemType := valueType.Elem() - - var singleElem any - var err error - switch elemType.Kind() { - case reflect.String: - singleElem, err = parseCLIArg[string](value) - case reflect.Int64: - singleElem, err = parseCLIArg[int64](value) - case reflect.Float64: - singleElem, err = parseCLIArg[float64](value) - case reflect.Bool: - singleElem, err = parseCLIArg[bool](value) - default: - // Check for special types by name - switch elemType.Name() { - case "DateTimeValue": - singleElem, err = parseCLIArg[DateTimeValue](value) - case "DateValue": - singleElem, err = parseCLIArg[DateValue](value) - case "TimeValue": - singleElem, err = parseCLIArg[TimeValue](value) - default: - // This handles []map[string]any - if elemType.Kind() == reflect.Map && elemType.Key().Kind() == reflect.String { - singleElem, err = parseCLIArg[map[string]any](value) - } else { - singleElem, err = parseCLIArg[any](value) - } - } - } - - if err != nil { - return err - } - - // Append the new element to the slice - sliceValue := reflect.ValueOf(c.value) - if !sliceValue.IsValid() || sliceValue.IsNil() { - // Create a new slice if the current one is nil - sliceValue = reflect.MakeSlice(valueType, 0, 1) - } - - // Append the new element - newElem := reflect.ValueOf(singleElem) - sliceValue = reflect.Append(sliceValue, newElem) - - // Set the updated slice back to c.value - c.value = sliceValue.Interface().(T) - } else { - // For non-slice types, simply parse and set the value - if parsedValue, err := parseCLIArg[T](value); err != nil { - return err - } else { - c.value = parsedValue - } - } - - return nil -} - -func (c *cliValue[T]) Get() any { - return c.value -} - -func (c *cliValue[T]) String() string { - switch v := any(c.value).(type) { - case string, int, int64, float64, bool, DateTimeValue, DateValue, TimeValue, - []string, []int, []int64, []float64, []bool, []DateTimeValue, []DateValue, []TimeValue: - // For basic types, use standard string representation - return fmt.Sprintf("%v", v) - - case *string, *int64, *float64, *bool, *DateTimeValue, *DateValue, *TimeValue: - // Pointer-to-primitive: nil renders as "null" (the CLI literal that produces it); - // non-nil derefs to the pointee's standard representation. - rv := reflect.ValueOf(v) - if rv.IsNil() { - return "null" - } - return fmt.Sprintf("%v", rv.Elem().Interface()) - - default: - // For complex types, convert to YAML - yamlBytes, err := yaml.MarshalWithOptions(c.value, yaml.Flow(true)) - if err != nil { - // Fall back to standard format if YAML conversion fails - return fmt.Sprintf("%v", c.value) - } - return string(yamlBytes) - } -} - -func (c *cliValue[T]) IsBoolFlag() bool { - _, ok := any(c.value).(bool) - return ok -} - -// Time-related value types -type DateValue string -type DateTimeValue string -type TimeValue string - -// String methods for time-related types -func (d DateValue) String() string { - return string(d) -} - -func (d DateTimeValue) String() string { - return string(d) -} - -func (t TimeValue) String() string { - return string(t) -} - -// parseTimeWithFormats attempts to parse a string using multiple formats -func parseTimeWithFormats(s string, formats []string) (time.Time, error) { - var lastErr error - for _, format := range formats { - t, err := time.Parse(format, s) - if err == nil { - return t, nil - } - lastErr = err - } - return time.Time{}, lastErr -} - -// Parse methods for time-related types -func (d *DateValue) Parse(s string) error { - formats := []string{ - "2006-01-02", - "01/02/2006", - "Jan 2, 2006", - "January 2, 2006", - "2-Jan-2006", - } - - t, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse date: %v", err) - } - - *d = DateValue(t.Format("2006-01-02")) - return nil -} - -func (d *DateTimeValue) Parse(s string) error { - formats := []string{ - time.RFC3339, - time.RFC3339Nano, - "2006-01-02T15:04:05", - "2006-01-02 15:04:05", - time.RFC1123, - time.RFC822, - time.ANSIC, - } - - t, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse datetime: %v", err) - } - - *d = DateTimeValue(t.Format(time.RFC3339)) - return nil -} - -func (t *TimeValue) Parse(s string) error { - formats := []string{ - "15:04:05", - "15:04:05.999999999Z07:00", - "3:04:05PM", - "3:04 PM", - "15:04", - time.Kitchen, - } - - parsedTime, err := parseTimeWithFormats(s, formats) - if err != nil { - return fmt.Errorf("unable to parse time: %v", err) - } - - *t = TimeValue(parsedTime.Format("15:04:05")) - return nil -} - -// Allow setting inner fields on other flags (e.g. --foo.baz can set the "baz" -// field on the --foo flag) -type SettableInnerField interface { - SetInnerField(string, any) -} - -// InnerFieldSeeder lets an InnerFlag prepare its outer flag's underlying value -// before dispatching SetInnerField. This is only meaningful for Flag[any] — -// the codegen output for nullable complex schemas — whose untyped-nil zero -// value would otherwise have no reflect.Kind for the inner-field switch to -// dispatch on. -type InnerFieldSeeder interface { - SeedInnerCollection(isArrayOfObjects bool) -} - -func (f *Flag[T]) SetInnerField(field string, val any) { - if f.value == nil { - f.value = &cliValue[T]{} - } - - if settableInnerField, ok := f.value.(SettableInnerField); ok { - settableInnerField.SetInnerField(field, val) - f.hasBeenSet = true - } else { - panic(fmt.Sprintf("Cannot set inner field: %v", f.value)) - } -} - -// SeedInnerCollection initializes a Flag[any]'s underlying value as an empty -// map[string]any or []map[string]any so subsequent SetInnerField calls have a -// dispatchable reflect.Kind. For typed Flag[T] this is a no-op: the type -// assertion fails and the existing reflect.Kind on the typed-nil zero value -// already routes correctly. -func (f *Flag[T]) SeedInnerCollection(isArrayOfObjects bool) { - if f.value == nil { - f.value = &cliValue[T]{} - } - cv, ok := f.value.(*cliValue[T]) - if !ok { - return - } - if reflect.ValueOf(cv.value).Kind() != reflect.Invalid { - return - } - if isArrayOfObjects { - if seed, ok := any([]map[string]any{}).(T); ok { - cv.value = seed - } - return - } - if seed, ok := any(map[string]any{}).(T); ok { - cv.value = seed - } -} - -func (c *cliValue[T]) SetInnerField(field string, val any) { - flagVal := c.value - flagValReflect := reflect.ValueOf(flagVal) - switch flagValReflect.Kind() { - case reflect.Slice: - if flagValReflect.Type().Elem().Kind() != reflect.Map { - return - } - - sliceLen := flagValReflect.Len() - if sliceLen > 0 { - // Check if the last element already has the InnerField - lastElement := flagValReflect.Index(sliceLen - 1).Interface().(map[string]any) - if _, hasInnerField := lastElement[field]; !hasInnerField { - // Last element doesn't have the field, set it - lastElement[field] = val - return - } - } - - // Create a new map and append it to the slice - newMap := map[string]any{field: val} - switch sliceVal := any(c.value).(type) { - case []map[string]any: - c.value = any(append(sliceVal, newMap)).(T) - case []any: - c.value = any(append(sliceVal, newMap)).(T) - } - - case reflect.Map: - mapVal, ok := any(flagVal).(map[string]any) - if !ok || mapVal == nil { - mapVal = map[string]any{field: val} - c.value = any(mapVal).(T) - } else { - mapVal[field] = val - } - } -} diff --git a/internal/requestflag/requestflag_test.go b/internal/requestflag/requestflag_test.go deleted file mode 100644 index 779bd57..0000000 --- a/internal/requestflag/requestflag_test.go +++ /dev/null @@ -1,1227 +0,0 @@ -package requestflag - -import ( - "encoding/json" - "fmt" - "testing" - "time" - - "github.com/goccy/go-yaml" - "github.com/stretchr/testify/assert" - "github.com/urfave/cli/v3" -) - -func TestDateValueParse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - wantErr bool - }{ - { - name: "ISO format", - input: "2023-05-15", - want: "2023-05-15", - wantErr: false, - }, - { - name: "US format", - input: "05/15/2023", - want: "2023-05-15", - wantErr: false, - }, - { - name: "Short month format", - input: "May 15, 2023", - want: "2023-05-15", - wantErr: false, - }, - { - name: "Long month format", - input: "January 15, 2023", - want: "2023-01-15", - wantErr: false, - }, - { - name: "British format", - input: "15-Jan-2023", - want: "2023-01-15", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a date", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var d DateValue - err := d.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, d.String()) - } - }) - } -} - -func TestDateTimeValueParse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - wantErr bool - }{ - { - name: "RFC3339", - input: "2023-05-15T14:30:45Z", - wantErr: false, - }, - { - name: "ISO with timezone", - input: "2023-05-15T14:30:45+02:00", - wantErr: false, - }, - { - name: "ISO without timezone", - input: "2023-05-15T14:30:45", - wantErr: false, - }, - { - name: "Space separated", - input: "2023-05-15 14:30:45", - wantErr: false, - }, - { - name: "RFC1123", - input: "Mon, 15 May 2023 14:30:45 GMT", - wantErr: false, - }, - { - name: "RFC822", - input: "15 May 23 14:30 GMT", - wantErr: false, - }, - { - name: "ANSIC", - input: "Mon Jan 2 15:04:05 2006", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a datetime", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var d DateTimeValue - err := d.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - - // Parse the string back to ensure it's valid RFC3339 - _, parseErr := time.Parse(time.RFC3339, d.String()) - assert.NoError(t, parseErr) - } - }) - } -} - -func TestTimeValueParse(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - want string - wantErr bool - }{ - { - name: "24-hour format", - input: "14:30:45", - want: "14:30:45", - wantErr: false, - }, - { - name: "12-hour format with seconds", - input: "2:30:45PM", - want: "14:30:45", - wantErr: false, - }, - { - name: "12-hour format without seconds", - input: "2:30 PM", - want: "14:30:00", - wantErr: false, - }, - { - name: "24-hour without seconds", - input: "14:30", - want: "14:30:00", - wantErr: false, - }, - { - name: "Kitchen format", - input: "2:30PM", - want: "14:30:00", - wantErr: false, - }, - { - name: "Invalid format", - input: "not a time", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var tv TimeValue - err := tv.Parse(tt.input) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.want, tv.String()) - } - }) - } -} - -func TestRequestParams(t *testing.T) { - t.Parallel() - - t.Run("map body type", func(t *testing.T) { - t.Parallel() - - // Create a mock command with flags - cmd := &cli.Command{ - Name: "test", - } - - // Create string flag with body path - stringFlag := &Flag[string]{ - Name: "string-flag", - Default: "default-string", - BodyPath: "string_field", - value: &cliValue[string]{value: "test-value"}, - hasBeenSet: true, - } - - // Create int flag with header path - intFlag := &Flag[int64]{ - Name: "int-flag", - Default: 42, - HeaderPath: "X-Int-Value", - value: &cliValue[int64]{value: 99}, - hasBeenSet: true, - } - - // Create bool flag with query path - boolFlag := &Flag[bool]{ - Name: "bool-flag", - Default: false, - QueryPath: "include_details", - value: &cliValue[bool]{value: true}, - hasBeenSet: true, - } - - // Create date flag with multiple paths - dateFlag := &Flag[DateValue]{ - Name: "date-flag", - Default: DateValue("2023-01-01"), - BodyPath: "effective_date", - HeaderPath: "X-Effective-Date", - QueryPath: "as_of_date", - value: &cliValue[DateValue]{value: DateValue("2023-05-15")}, - hasBeenSet: true, - } - - // Create flag with no path - noPathFlag := &Flag[string]{ - Name: "no-path-flag", - Default: "no-path", - value: &cliValue[string]{value: "no-path-value"}, - hasBeenSet: true, - } - - // Create unset flag - unsetFlag := &Flag[string]{ - Name: "unset-flag", - Default: "unset", - BodyPath: "should_not_appear", - value: &cliValue[string]{value: "unset-value"}, - hasBeenSet: false, - } - - cmd.Flags = []cli.Flag{stringFlag, intFlag, boolFlag, dateFlag, noPathFlag, unsetFlag} - - // Test the RequestParams function - contents := ExtractRequestContents(cmd) - - // Verify query parameters - assert.Equal(t, true, contents.Queries["include_details"]) - assert.Equal(t, DateValue("2023-05-15"), contents.Queries["as_of_date"]) - assert.Len(t, contents.Queries, 2) - - // Verify headers - assert.Equal(t, int64(99), contents.Headers["X-Int-Value"]) - assert.Equal(t, DateValue("2023-05-15"), contents.Headers["X-Effective-Date"]) - assert.Len(t, contents.Headers, 2) - - // Verify body - bodyMap, ok := contents.Body.(map[string]any) - assert.True(t, ok, "Expected body to be map[string]any, got %T", contents.Body) - assert.Equal(t, "test-value", bodyMap["string_field"]) - assert.Equal(t, DateValue("2023-05-15"), bodyMap["effective_date"]) - assert.Len(t, bodyMap, 2) - - // Verify the unset flag didn't make it into the maps - assert.NotContains(t, contents.Body, "should_not_appear") - }) - - t.Run("non-map body type", func(t *testing.T) { - t.Parallel() - - // Create a mock command with flags - cmd := &cli.Command{ - Name: "test", - Flags: []cli.Flag{ - &Flag[int64]{ - Name: "int-body-flag", - Default: 0, - BodyRoot: true, - }, - }, - } - cmd.Set("int-body-flag", "42") - - contents := ExtractRequestContents(cmd) - intBody, ok := contents.Body.(int64) - assert.True(t, ok, "Expected body to be int64, got %T", contents.Body) - assert.Equal(t, int64(42), intBody) - }) -} - -func TestFlagSet(t *testing.T) { - t.Parallel() - - strFlag := &Flag[string]{ - Name: "string-flag", - Default: "default-string", - } - - superstitiousIntFlag := &Flag[int64]{ - Name: "int-flag", - Default: 42, - Validator: func(val int64) error { - if val == 13 { - return fmt.Errorf("Unlucky number!") - } - return nil - }, - } - - boolFlag := &Flag[bool]{ - Name: "bool-flag", - Default: false, - } - - // Test initialization and setting - t.Run("PreParse initialization", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, strFlag.PreParse()) - assert.True(t, strFlag.applied) - assert.Equal(t, "default-string", strFlag.Get()) - }) - - t.Run("Set string flag", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, strFlag.Set("string-flag", "new-value")) - assert.Equal(t, "new-value", strFlag.Get()) - assert.True(t, strFlag.IsSet()) - }) - - t.Run("Set int flag with valid value", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, superstitiousIntFlag.Set("int-flag", "100")) - assert.Equal(t, int64(100), superstitiousIntFlag.Get()) - assert.True(t, superstitiousIntFlag.IsSet()) - }) - - t.Run("Set int flag with invalid value", func(t *testing.T) { - t.Parallel() - - assert.Error(t, superstitiousIntFlag.Set("int-flag", "not-an-int")) - }) - - t.Run("Set int flag with validator failing", func(t *testing.T) { - t.Parallel() - - assert.Error(t, superstitiousIntFlag.Set("int-flag", "13")) - }) - - t.Run("Set bool flag", func(t *testing.T) { - t.Parallel() - - assert.NoError(t, boolFlag.Set("bool-flag", "true")) - assert.Equal(t, true, boolFlag.Get()) - assert.True(t, boolFlag.IsSet()) - }) - - t.Run("Set slice flag with multiple values", func(t *testing.T) { - t.Parallel() - - sliceFlag := &Flag[[]int64]{ - Name: "slice-flag", - Default: []int64{}, - } - - // Initialize the flag - assert.NoError(t, sliceFlag.PreParse()) - - // First set - assert.NoError(t, sliceFlag.Set("slice-flag", "10")) - - // Subsequent setting should append, not replace - assert.NoError(t, sliceFlag.Set("slice-flag", "20")) - assert.NoError(t, sliceFlag.Set("slice-flag", "30")) - - // Verify that we have both values in the slice - result := sliceFlag.Get() - assert.Equal(t, []int64{10, 20, 30}, result) - assert.True(t, sliceFlag.IsSet()) - }) - - t.Run("Set slice flag with a nonempty default", func(t *testing.T) { - t.Parallel() - - sliceFlag := &Flag[[]int64]{ - Name: "slice-flag", - Default: []int64{99, 100}, - } - - assert.NoError(t, sliceFlag.PreParse()) - assert.NoError(t, sliceFlag.Set("slice-flag", "10")) - assert.NoError(t, sliceFlag.Set("slice-flag", "20")) - assert.NoError(t, sliceFlag.Set("slice-flag", "30")) - - // Verify that we have clobbered the default value instead of appending - // to it. - result := sliceFlag.Get() - assert.Equal(t, []int64{10, 20, 30}, result) - assert.True(t, sliceFlag.IsSet()) - }) -} - -func TestParseTimeWithFormats(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - input string - formats []string - wantTime time.Time - wantErr bool - }{ - { - name: "RFC3339 format", - input: "2023-05-15T14:30:45Z", - formats: []string{time.RFC3339}, - wantTime: time.Date(2023, 5, 15, 14, 30, 45, 0, time.UTC), - wantErr: false, - }, - { - name: "Multiple formats - first matches", - input: "2023-05-15", - formats: []string{"2006-01-02", time.RFC3339}, - wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), - wantErr: false, - }, - { - name: "Multiple formats - second matches", - input: "15/05/2023", - formats: []string{"2006-01-02", "02/01/2006"}, - wantTime: time.Date(2023, 5, 15, 0, 0, 0, 0, time.UTC), - wantErr: false, - }, - { - name: "No matching format", - input: "not a date", - formats: []string{"2006-01-02", time.RFC3339}, - wantTime: time.Time{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - got, err := parseTimeWithFormats(tt.input, tt.formats) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.True(t, tt.wantTime.Equal(got), "Expected %v, got %v", tt.wantTime, got) - } - }) - } -} - -func TestYamlHandling(t *testing.T) { - t.Parallel() - - // Test with any value - t.Run("Parse YAML to any", func(t *testing.T) { - t.Parallel() - - cv := &cliValue[any]{} - err := cv.Set("name: test\nvalue: 42\n") - assert.NoError(t, err) - - // The value should be a map - val, ok := cv.Get().(map[string]any) - assert.True(t, ok, "Expected map[string]any, got %T", cv.Get()) - - if ok { - assert.Equal(t, "test", val["name"]) - assert.Equal(t, uint64(42), val["value"]) - } - - // The string representation should be valid YAML - strVal := cv.String() - var parsed map[string]any - err = yaml.Unmarshal([]byte(strVal), &parsed) - assert.NoError(t, err) - assert.Equal(t, "test", parsed["name"]) - assert.Equal(t, uint64(42), parsed["value"]) - }) - - // Test with array - t.Run("Parse YAML array", func(t *testing.T) { - t.Parallel() - - cv := &cliValue[any]{} - err := cv.Set("- item1\n- item2\n- item3\n") - assert.NoError(t, err) - - // The value should be a slice - val, ok := cv.Get().([]any) - assert.True(t, ok, "Expected []any, got %T", cv.Get()) - - if ok { - assert.Len(t, val, 3) - assert.Equal(t, "item1", val[0]) - assert.Equal(t, "item2", val[1]) - assert.Equal(t, "item3", val[2]) - } - }) - - t.Run("Parse @file.txt as YAML", func(t *testing.T) { - t.Parallel() - - flag := &Flag[any]{ - Name: "file-flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("file-flag", "@file.txt")) - - val := flag.Get() - assert.Equal(t, "@file.txt", val) - }) - - t.Run("Parse @file.txt list as YAML", func(t *testing.T) { - t.Parallel() - - flag := &Flag[[]any]{ - Name: "file-flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("file-flag", "@file1.txt")) - assert.NoError(t, flag.Set("file-flag", "@file2.txt")) - - val := flag.Get() - assert.Equal(t, []any{"@file1.txt", "@file2.txt"}, val) - }) - - t.Run("Parse identifiers as YAML", func(t *testing.T) { - t.Parallel() - - tests := []string{ - "hello", - "e4e355fa-b03b-4c57-a73d-25c9733eec79", - "foo_bar", - "Color.Red", - "aGVsbG8=", - } - for _, test := range tests { - flag := &Flag[any]{ - Name: "flag", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("flag", test)) - - val := flag.Get() - assert.Equal(t, test, val) - } - - for _, test := range tests { - flag := &Flag[[]any]{ - Name: "identifier", - Default: nil, - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("identifier", test)) - assert.NoError(t, flag.Set("identifier", test)) - - val := flag.Get() - assert.Equal(t, []any{test, test}, val) - } - }) - - // Test with invalid YAML - t.Run("Parse invalid YAML", func(t *testing.T) { - t.Parallel() - - invalidYaml := `[not closed` - cv := &cliValue[any]{} - err := cv.Set(invalidYaml) - assert.Error(t, err) - }) -} - -// TestNullLiteralHandling pins how each Flag[T] type handles the literal value "null" -// when passed via the CLI. Pointer-typed flags serialize nil as JSON null, which is how -// nullable body fields (`anyOf: [T, null]` / `{nullable: true}`) let users clear a field -// via `--foo null`. Non-pointer primitive flags treat "null" as a raw value — these are -// non-nullable schemas where explicit null has no API semantics anyway. -func TestNullLiteralHandling(t *testing.T) { - t.Parallel() - - assertJSONBody := func(t *testing.T, value any, expected string) { - t.Helper() - body, err := json.Marshal(map[string]any{"foo": value}) - assert.NoError(t, err) - assert.JSONEq(t, expected, string(body)) - } - - t.Run("Flag[any] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[any]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[string] null is the raw string \"null\"", func(t *testing.T) { - t.Parallel() - cv := &cliValue[string]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":"null"}`) - }) - - t.Run("Flag[int64] null errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[int64]{} - assert.Error(t, cv.Set("null")) - }) - - t.Run("Flag[*string] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*string]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*string] value sends the string", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*string]{} - assert.NoError(t, cv.Set("1.1")) - assertJSONBody(t, cv.Get(), `{"foo":"1.1"}`) - }) - - t.Run("Flag[*int64] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*int64]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*int64] value sends the integer", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*int64]{} - assert.NoError(t, cv.Set("42")) - assertJSONBody(t, cv.Get(), `{"foo":42}`) - }) - - t.Run("Flag[*int64] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*int64]{} - assert.Error(t, cv.Set("not-an-int")) - }) - - t.Run("Flag[*bool] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*bool]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*bool] value sends the boolean", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*bool]{} - assert.NoError(t, cv.Set("true")) - assertJSONBody(t, cv.Get(), `{"foo":true}`) - }) - - t.Run("Flag[*float64] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*float64]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*float64] value sends the float", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*float64]{} - assert.NoError(t, cv.Set("1.5")) - assertJSONBody(t, cv.Get(), `{"foo":1.5}`) - }) - - t.Run("Flag[*float64] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*float64]{} - assert.Error(t, cv.Set("not-a-float")) - }) - - t.Run("Flag[*DateValue] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateValue]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*DateValue] value sends the date", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateValue]{} - assert.NoError(t, cv.Set("2023-05-15")) - assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15"}`) - }) - - t.Run("Flag[*DateValue] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateValue]{} - assert.Error(t, cv.Set("not-a-date")) - }) - - t.Run("Flag[*DateTimeValue] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateTimeValue]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*DateTimeValue] value sends the datetime", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateTimeValue]{} - assert.NoError(t, cv.Set("2023-05-15T14:30:45Z")) - assertJSONBody(t, cv.Get(), `{"foo":"2023-05-15T14:30:45Z"}`) - }) - - t.Run("Flag[*DateTimeValue] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*DateTimeValue]{} - assert.Error(t, cv.Set("not-a-datetime")) - }) - - t.Run("Flag[*TimeValue] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*TimeValue]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) - - t.Run("Flag[*TimeValue] value sends the time", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*TimeValue]{} - assert.NoError(t, cv.Set("14:30:45")) - assertJSONBody(t, cv.Get(), `{"foo":"14:30:45"}`) - }) - - t.Run("Flag[*TimeValue] invalid value errors", func(t *testing.T) { - t.Parallel() - cv := &cliValue[*TimeValue]{} - assert.Error(t, cv.Set("not-a-time")) - }) - - // Nullable maps don't need pointer wrapping — a nil map already marshals as JSON null. - t.Run("Flag[map[string]any] null sends JSON null", func(t *testing.T) { - t.Parallel() - cv := &cliValue[map[string]any]{} - assert.NoError(t, cv.Set("null")) - assertJSONBody(t, cv.Get(), `{"foo":null}`) - }) -} - -func TestFlagTypeNames(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - flag cli.DocGenerationFlag - expected string - }{ - {"string", &Flag[string]{}, "string"}, - {"int64", &Flag[int64]{}, "int"}, - {"float64", &Flag[float64]{}, "float"}, - {"bool", &Flag[bool]{}, "boolean"}, - {"string slice", &Flag[[]string]{}, "string"}, - {"date", &Flag[DateValue]{}, "date"}, - {"datetime", &Flag[DateTimeValue]{}, "datetime"}, - {"time", &Flag[TimeValue]{}, "time"}, - {"date slice", &Flag[[]DateValue]{}, "date"}, - {"datetime slice", &Flag[[]DateTimeValue]{}, "datetime"}, - {"time slice", &Flag[[]TimeValue]{}, "time"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - typeName := tt.flag.TypeName() - assert.Equal(t, tt.expected, typeName, "Expected type name %q, got %q", tt.expected, typeName) - }) - } -} - -// TestInnerFlagDispatchOnUntypedFlag pins inner-flag behavior for `Flag[any]`, -// which is the codegen output for nullable complex schemas (`anyOf: [T, null]` -// or `{nullable: true}`). The untyped-nil zero value carries no reflect.Kind, -// so SetInnerField has nowhere to dispatch the assignment — without explicit -// help the inner-field value silently drops. -func TestInnerFlagDispatchOnUntypedFlag(t *testing.T) { - t.Parallel() - - t.Run("nullable array of objects appends element from inner flag", func(t *testing.T) { - t.Parallel() - outer := &Flag[any]{Name: "mcp-server"} - assert.NoError(t, outer.PreParse()) - - nameFlag := &InnerFlag[string]{ - Name: "mcp-server.name", InnerField: "name", - OuterFlag: outer, OuterIsArrayOfObjects: true, - } - assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) - - body, err := json.Marshal(map[string]any{"foo": outer.Get()}) - assert.NoError(t, err) - assert.JSONEq(t, `{"foo":[{"name":"first"}]}`, string(body)) - }) - - t.Run("nullable object sets field from inner flag", func(t *testing.T) { - t.Parallel() - outer := &Flag[any]{Name: "metadata"} - assert.NoError(t, outer.PreParse()) - - keyFlag := &InnerFlag[string]{ - Name: "metadata.key", InnerField: "key", OuterFlag: outer, - } - assert.NoError(t, keyFlag.Set("metadata.key", "value")) - - body, err := json.Marshal(map[string]any{"foo": outer.Get()}) - assert.NoError(t, err) - assert.JSONEq(t, `{"foo":{"key":"value"}}`, string(body)) - }) - - t.Run("multiple inner flags merge into the trailing element", func(t *testing.T) { - t.Parallel() - outer := &Flag[any]{Name: "mcp-server"} - assert.NoError(t, outer.PreParse()) - - nameFlag := &InnerFlag[string]{ - Name: "mcp-server.name", InnerField: "name", - OuterFlag: outer, OuterIsArrayOfObjects: true, - } - urlFlag := &InnerFlag[string]{ - Name: "mcp-server.url", InnerField: "url", - OuterFlag: outer, OuterIsArrayOfObjects: true, - } - assert.NoError(t, nameFlag.Set("mcp-server.name", "first")) - assert.NoError(t, urlFlag.Set("mcp-server.url", "https://example.com")) - - body, err := json.Marshal(map[string]any{"foo": outer.Get()}) - assert.NoError(t, err) - assert.JSONEq(t, `{"foo":[{"name":"first","url":"https://example.com"}]}`, string(body)) - }) -} - -func TestApplyStdinDataToFlags(t *testing.T) { - t.Parallel() - - t.Run("sets query path flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"account_id": "acct_123"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "acct_123", flag.Get()) - }) - - t.Run("sets header path flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "idempotency-key", - HeaderPath: "Idempotency-Key", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"Idempotency-Key": "key-xyz"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "key-xyz", flag.Get()) - }) - - t.Run("does not set body path flag from piped data", func(t *testing.T) { - t.Parallel() - - // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. - flag := &Flag[string]{ - Name: "message", - BodyPath: "message", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"message": "hello world"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("does not override flag already set via CLI", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - assert.NoError(t, flag.Set("account-id", "explicit_value")) - - data := map[string]any{"account_id": "piped_value"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - // The explicitly-set value should win. - assert.Equal(t, "explicit_value", flag.Get()) - }) - - t.Run("sets integer query flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[int64]{ - Name: "page-size", - QueryPath: "page_size", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"page_size": int64(50)} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, int64(50), flag.Get()) - }) - - t.Run("sets boolean query flag from piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[bool]{ - Name: "include-deleted", - QueryPath: "include_deleted", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"include_deleted": true} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, true, flag.Get()) - }) - - t.Run("resolves query path flag via data alias", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - DataAliases: []string{"accountId", "account"}, - } - assert.NoError(t, flag.PreParse()) - - // Use one of the aliases as the key in piped data. - data := map[string]any{"accountId": "acct_alias"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "acct_alias", flag.Get()) - }) - - t.Run("does not set body path flag via data alias", func(t *testing.T) { - t.Parallel() - - // Body params are handled by the maps.Copy merge in flagOptions, not by ApplyStdinDataToFlags. - flag := &Flag[string]{ - Name: "user-name", - BodyPath: "user_name", - DataAliases: []string{"userName", "username"}, - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"userName": "alice"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("ignores flags with no matching key in piped data", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"other_key": "value"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("ignores flags with no path set", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "some-flag", - // No QueryPath, HeaderPath, or BodyPath - } - assert.NoError(t, flag.PreParse()) - - data := map[string]any{"some-flag": "value"} - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, flag.IsSet()) - }) - - t.Run("handles multiple flags from piped data", func(t *testing.T) { - t.Parallel() - - accountFlag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - limitFlag := &Flag[int64]{ - Name: "limit", - QueryPath: "limit", - } - assert.NoError(t, accountFlag.PreParse()) - assert.NoError(t, limitFlag.PreParse()) - - data := map[string]any{ - "account_id": "acct_abc", - "limit": int64(25), - } - cmd := &cli.Command{Flags: []cli.Flag{accountFlag, limitFlag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, accountFlag.IsSet()) - assert.Equal(t, "acct_abc", accountFlag.Get()) - assert.True(t, limitFlag.IsSet()) - assert.Equal(t, int64(25), limitFlag.Get()) - }) - - t.Run("sets inner flag from nested piped data under outer body path", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "address", - BodyPath: "address", - } - assert.NoError(t, outer.PreParse()) - - cityInner := &InnerFlag[string]{ - Name: "address.city", - InnerField: "city", - OuterFlag: outer, - } - - data := map[string]any{ - "address": map[string]any{"city": "San Francisco"}, - } - cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - // InnerFlag.IsSet() is always false by design; verify the value was written - // into the outer flag's underlying map instead. - outerVal, ok := outer.Get().(map[string]any) - assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) - assert.Equal(t, "San Francisco", outerVal["city"]) - }) - - t.Run("sets inner flag via data alias in nested piped data", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "address", - BodyPath: "address", - } - assert.NoError(t, outer.PreParse()) - - cityInner := &InnerFlag[string]{ - Name: "address.city", - InnerField: "city", - DataAliases: []string{"cityName"}, - OuterFlag: outer, - } - - // Use the alias in piped data. - data := map[string]any{ - "address": map[string]any{"cityName": "Portland"}, - } - cmd := &cli.Command{Flags: []cli.Flag{outer, cityInner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - // InnerFlag.IsSet() is always false by design; verify the value was written - // into the outer flag's underlying map instead. - outerVal, ok := outer.Get().(map[string]any) - assert.True(t, ok, "expected outer flag value to be map[string]any, got %T", outer.Get()) - assert.Equal(t, "Portland", outerVal["city"]) - }) - - t.Run("does not set inner flag when outer flag has no body path", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "options", - // No BodyPath set - } - assert.NoError(t, outer.PreParse()) - - inner := &InnerFlag[string]{ - Name: "options.key", - InnerField: "key", - OuterFlag: outer, - } - - data := map[string]any{ - "options": map[string]any{"key": "value"}, - } - cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, inner.IsSet()) - }) - - t.Run("does not set inner flag when piped data has no nested map for outer path", func(t *testing.T) { - t.Parallel() - - outer := &Flag[map[string]any]{ - Name: "address", - BodyPath: "address", - } - assert.NoError(t, outer.PreParse()) - - inner := &InnerFlag[string]{ - Name: "address.city", - InnerField: "city", - OuterFlag: outer, - } - - // The outer body path key is missing from the piped data. - data := map[string]any{"other": "value"} - cmd := &cli.Command{Flags: []cli.Flag{outer, inner}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.False(t, inner.IsSet()) - }) - - t.Run("canonical path key takes precedence over alias when both are present", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - DataAliases: []string{"accountId"}, - } - assert.NoError(t, flag.PreParse()) - - // Both canonical and alias present — canonical should win because it's checked first. - data := map[string]any{ - "account_id": "canonical_value", - "accountId": "alias_value", - } - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, data)) - - assert.True(t, flag.IsSet()) - assert.Equal(t, "canonical_value", flag.Get()) - }) - - t.Run("empty data map does not set any flags", func(t *testing.T) { - t.Parallel() - - flag := &Flag[string]{ - Name: "account-id", - QueryPath: "account_id", - } - assert.NoError(t, flag.PreParse()) - - cmd := &cli.Command{Flags: []cli.Flag{flag}} - assert.NoError(t, ApplyStdinDataToFlags(cmd, map[string]any{})) - - assert.False(t, flag.IsSet()) - }) -} diff --git a/npm/.gitignore b/npm/.gitignore deleted file mode 100644 index 7656a68..0000000 --- a/npm/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -bin/.agentmail -bin/.agentmail.exe -.tmp/ -node_modules/ diff --git a/npm/README.md b/npm/README.md deleted file mode 100644 index 290695a..0000000 --- a/npm/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# AgentMail CLI - -The official CLI for the [AgentMail API](https://docs.agentmail.to). - -## Installation - -```sh -npm install -g agentmail-cli -``` - -## Setup - -```sh -export AGENTMAIL_API_KEY=am_us_xxx -``` - -## Usage - -```sh -agentmail inboxes list -agentmail inboxes create --display-name "My Inbox" -agentmail inboxes:messages send --inbox-id inb_xxx --to user@example.com --subject "Hello" --text "Hi" -agentmail inboxes:threads list --inbox-id inb_xxx -``` - -Run `agentmail --help` to see all available commands. - -## Documentation - -[docs.agentmail.to](https://docs.agentmail.to) diff --git a/npm/agentmail-cli-0.4.0.tgz b/npm/agentmail-cli-0.4.0.tgz deleted file mode 100644 index 3352732..0000000 Binary files a/npm/agentmail-cli-0.4.0.tgz and /dev/null differ diff --git a/npm/bin/agentmail b/npm/bin/agentmail deleted file mode 100755 index 1af86f8..0000000 --- a/npm/bin/agentmail +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node - -const { execFileSync } = require("child_process"); -const path = require("path"); - -const ext = process.platform === "win32" ? ".exe" : ""; -const binary = path.join(__dirname, `.agentmail${ext}`); - -try { - execFileSync(binary, process.argv.slice(2), { stdio: "inherit" }); -} catch (err) { - if (err.status != null) { - process.exit(err.status); - } - console.error(`Failed to run agentmail: ${err.message}`); - console.error("Try reinstalling: npm install -g @agentmail/cli"); - process.exit(1); -} diff --git a/npm/package.json b/npm/package.json deleted file mode 100644 index d4eb60c..0000000 --- a/npm/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "agentmail-cli", - "version": "0.4.1", - "description": "The official CLI for the AgentMail API", - "bin": { - "agentmail": "./bin/agentmail" - }, - "scripts": { - "postinstall": "node scripts/postinstall.js" - }, - "binaryVersion": "0.4.0", - "keywords": ["agentmail", "cli", "email", "api"], - "homepage": "https://agentmail.to", - "repository": { - "type": "git", - "url": "https://github.com/agentmail-to/agentmail-cli" - }, - "license": "Apache-2.0", - "engines": { - "node": ">=16" - } -} diff --git a/npm/scripts/postinstall.js b/npm/scripts/postinstall.js deleted file mode 100644 index f33bd94..0000000 --- a/npm/scripts/postinstall.js +++ /dev/null @@ -1,122 +0,0 @@ -const { execSync } = require("child_process"); -const fs = require("fs"); -const path = require("path"); -const https = require("https"); -const { createWriteStream } = require("fs"); - -const REPO = "agentmail-to/agentmail-cli"; -const BINARY_NAME = "agentmail"; - -const PLATFORM_MAP = { - darwin: "macos", - linux: "linux", - win32: "windows", -}; - -const ARCH_MAP = { - arm64: "arm64", - x64: "amd64", - ia32: "386", -}; - -function getPlatformArch() { - const platform = PLATFORM_MAP[process.platform]; - const arch = ARCH_MAP[process.arch]; - - if (!platform || !arch) { - console.error( - `Unsupported platform: ${process.platform} ${process.arch}` - ); - process.exit(1); - } - - return { platform, arch }; -} - -function getAssetName(version, platform, arch) { - const ext = platform === "linux" ? "tar.gz" : "zip"; - return `${BINARY_NAME}_${version}_${platform}_${arch}.${ext}`; -} - -function fetch(url) { - return new Promise((resolve, reject) => { - https - .get(url, { headers: { "User-Agent": "agentmail-cli-npm" } }, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - return fetch(res.headers.location).then(resolve).catch(reject); - } - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} for ${url}`)); - } - resolve(res); - }) - .on("error", reject); - }); -} - -async function download(url, dest) { - const res = await fetch(url); - return new Promise((resolve, reject) => { - const file = createWriteStream(dest); - res.pipe(file); - file.on("finish", () => file.close(resolve)); - file.on("error", reject); - }); -} - -function extract(archive, destDir) { - if (archive.endsWith(".tar.gz")) { - execSync(`tar -xzf "${archive}" -C "${destDir}"`, { stdio: "ignore" }); - } else if (archive.endsWith(".zip")) { - if (process.platform === "win32") { - execSync( - `powershell -Command "Expand-Archive -Path '${archive}' -DestinationPath '${destDir}' -Force"`, - { stdio: "ignore" } - ); - } else { - execSync(`unzip -o "${archive}" -d "${destDir}"`, { stdio: "ignore" }); - } - } -} - -async function main() { - const { platform, arch } = getPlatformArch(); - const pkg = require("../package.json"); - const version = pkg.binaryVersion || pkg.version; - - const assetName = getAssetName(version, platform, arch); - const url = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`; - - const binDir = path.join(__dirname, "..", "bin"); - const tmpDir = path.join(__dirname, "..", ".tmp"); - - fs.mkdirSync(binDir, { recursive: true }); - fs.mkdirSync(tmpDir, { recursive: true }); - - const archivePath = path.join(tmpDir, assetName); - - console.log(`Downloading ${BINARY_NAME} v${version} for ${platform}/${arch}...`); - - try { - await download(url, archivePath); - } catch (err) { - console.error(`Failed to download ${url}: ${err.message}`); - process.exit(1); - } - - extract(archivePath, tmpDir); - - const binaryExt = platform === "windows" ? ".exe" : ""; - const binarySource = path.join(tmpDir, `${BINARY_NAME}${binaryExt}`); - const binaryDest = path.join(binDir, `.${BINARY_NAME}${binaryExt}`); - - fs.copyFileSync(binarySource, binaryDest); - fs.chmodSync(binaryDest, 0o755); - - // Cleanup - fs.rmSync(tmpDir, { recursive: true, force: true }); - - console.log(`${BINARY_NAME} v${version} installed successfully.`); -} - -main(); diff --git a/pkg/cmd/agent.go b/pkg/cmd/agent.go deleted file mode 100644 index bc75542..0000000 --- a/pkg/cmd/agent.go +++ /dev/null @@ -1,145 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var agentSignUp = cli.Command{ - Name: "sign-up", - Usage: "Create a new agent organization with an inbox and API key. This endpoint is for\nsigning up for the first time. If you've already signed up, you're all set —\njust use your existing API key.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "human-email", - Usage: "Email address of the human who owns the agent. A 6-digit OTP will be sent to this address.", - Required: true, - BodyPath: "human_email", - }, - &requestflag.Flag[string]{ - Name: "username", - Usage: `Username for the auto-created inbox (e.g. "my-agent" creates my-agent@agentmail.to).`, - Required: true, - BodyPath: "username", - }, - &requestflag.Flag[*string]{ - Name: "referrer", - Usage: "The channel that drove this sign-up — where the agent or its developer discovered AgentMail\n(e.g. `agent.email`, a partner URL, a campaign tag). Answers \"where did this sign-up come from\".\nMax 2048 characters.", - BodyPath: "referrer", - }, - &requestflag.Flag[*string]{ - Name: "source", - Usage: "The SDK, framework, or platform issuing this sign-up (e.g. `agentmail-python`, `agentmail-cli`, `agentmail-mcp`).\nIdentifies the caller — answers \"who is signing up\".\nMax 2048 characters.", - BodyPath: "source", - }, - }, - Action: handleAgentSignUp, - HideHelpCommand: true, -} - -var agentVerify = cli.Command{ - Name: "verify", - Usage: "Verify an agent organization using the 6-digit OTP sent to the human's email\nduring sign-up.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "otp-code", - Usage: "6-digit verification code sent to the human's email address.", - Required: true, - BodyPath: "otp_code", - }, - }, - Action: handleAgentVerify, - HideHelpCommand: true, -} - -func handleAgentSignUp(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.AgentSignUpParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Agent.SignUp(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "agent sign-up", - Transform: transform, - }) -} - -func handleAgentVerify(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.AgentVerifyParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Agent.Verify(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "agent verify", - Transform: transform, - }) -} diff --git a/pkg/cmd/agent_test.go b/pkg/cmd/agent_test.go deleted file mode 100644 index 8f744a0..0000000 --- a/pkg/cmd/agent_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestAgentSignUp(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "agent", "sign-up", - "--human-email", "human_email", - "--username", "username", - "--referrer", "referrer", - "--source", "source", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "human_email: human_email\n" + - "username: username\n" + - "referrer: referrer\n" + - "source: source\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "agent", "sign-up", - ) - }) -} - -func TestAgentVerify(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "agent", "verify", - "--otp-code", "otp_code", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("otp_code: otp_code") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "agent", "verify", - ) - }) -} diff --git a/pkg/cmd/apikey.go b/pkg/cmd/apikey.go deleted file mode 100644 index 1f75a1e..0000000 --- a/pkg/cmd/apikey.go +++ /dev/null @@ -1,361 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var apiKeysCreate = requestflag.WithInnerFlags(cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*string]{ - Name: "name", - Usage: "Name of api key.", - BodyPath: "name", - }, - &requestflag.Flag[map[string]any]{ - Name: "permissions", - Usage: "Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted.", - BodyPath: "permissions", - }, - }, - Action: handleAPIKeysCreate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "permissions": { - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-create", - Usage: "Create API keys.", - InnerField: "api_key_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-delete", - Usage: "Delete API keys.", - InnerField: "api_key_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-read", - Usage: "Read API keys.", - InnerField: "api_key_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-create", - Usage: "Create domains.", - InnerField: "domain_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-delete", - Usage: "Delete domains.", - InnerField: "domain_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-read", - Usage: "Read domain details.", - InnerField: "domain_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-update", - Usage: "Update domains.", - InnerField: "domain_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-create", - Usage: "Create drafts.", - InnerField: "draft_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-delete", - Usage: "Delete drafts.", - InnerField: "draft_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-read", - Usage: "Read drafts.", - InnerField: "draft_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-send", - Usage: "Send drafts.", - InnerField: "draft_send", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-update", - Usage: "Update drafts.", - InnerField: "draft_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-create", - Usage: "Create new inboxes.", - InnerField: "inbox_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-delete", - Usage: "Delete inboxes.", - InnerField: "inbox_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-read", - Usage: "Read inbox details.", - InnerField: "inbox_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-update", - Usage: "Update inbox settings.", - InnerField: "inbox_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-blocked-read", - Usage: "Access messages labeled blocked.", - InnerField: "label_blocked_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-spam-read", - Usage: "Access messages labeled spam.", - InnerField: "label_spam_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-trash-read", - Usage: "Access messages labeled trash.", - InnerField: "label_trash_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-create", - Usage: "Create list entries.", - InnerField: "list_entry_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-delete", - Usage: "Delete list entries.", - InnerField: "list_entry_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-read", - Usage: "Read list entries.", - InnerField: "list_entry_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-read", - Usage: "Read messages.", - InnerField: "message_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-send", - Usage: "Send messages.", - InnerField: "message_send", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-update", - Usage: "Update message labels.", - InnerField: "message_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.metrics-read", - Usage: "Read metrics.", - InnerField: "metrics_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-create", - Usage: "Create pods.", - InnerField: "pod_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-delete", - Usage: "Delete pods.", - InnerField: "pod_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-read", - Usage: "Read pods.", - InnerField: "pod_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.thread-delete", - Usage: "Delete threads.", - InnerField: "thread_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.thread-read", - Usage: "Read threads.", - InnerField: "thread_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-create", - Usage: "Create webhooks.", - InnerField: "webhook_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-delete", - Usage: "Delete webhooks.", - InnerField: "webhook_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-read", - Usage: "Read webhook configurations.", - InnerField: "webhook_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-update", - Usage: "Update webhooks.", - InnerField: "webhook_update", - }, - }, -}) - -var apiKeysList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleAPIKeysList, - HideHelpCommand: true, -} - -var apiKeysDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "api-key-id", - Usage: "ID of api key.", - Required: true, - PathParam: "api_key_id", - }, - }, - Action: handleAPIKeysDelete, - HideHelpCommand: true, -} - -func handleAPIKeysCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.APIKeyNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.APIKeys.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "api-keys create", - Transform: transform, - }) -} - -func handleAPIKeysList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.APIKeyListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.APIKeys.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "api-keys list", - Transform: transform, - }) -} - -func handleAPIKeysDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("api-key-id") && len(unusedArgs) > 0 { - cmd.Set("api-key-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.APIKeys.Delete(ctx, cmd.Value("api-key-id").(string), options...) -} diff --git a/pkg/cmd/apikey_test.go b/pkg/cmd/apikey_test.go deleted file mode 100644 index 2dd1fa4..0000000 --- a/pkg/cmd/apikey_test.go +++ /dev/null @@ -1,144 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" -) - -func TestAPIKeysCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "api-keys", "create", - "--name", "name", - "--permissions", "{api_key_create: true, api_key_delete: true, api_key_read: true, domain_create: true, domain_delete: true, domain_read: true, domain_update: true, draft_create: true, draft_delete: true, draft_read: true, draft_send: true, draft_update: true, inbox_create: true, inbox_delete: true, inbox_read: true, inbox_update: true, label_blocked_read: true, label_spam_read: true, label_trash_read: true, list_entry_create: true, list_entry_delete: true, list_entry_read: true, message_read: true, message_send: true, message_update: true, metrics_read: true, pod_create: true, pod_delete: true, pod_read: true, thread_delete: true, thread_read: true, webhook_create: true, webhook_delete: true, webhook_read: true, webhook_update: true}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(apiKeysCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "api-keys", "create", - "--name", "name", - "--permissions.api-key-create=true", - "--permissions.api-key-delete=true", - "--permissions.api-key-read=true", - "--permissions.domain-create=true", - "--permissions.domain-delete=true", - "--permissions.domain-read=true", - "--permissions.domain-update=true", - "--permissions.draft-create=true", - "--permissions.draft-delete=true", - "--permissions.draft-read=true", - "--permissions.draft-send=true", - "--permissions.draft-update=true", - "--permissions.inbox-create=true", - "--permissions.inbox-delete=true", - "--permissions.inbox-read=true", - "--permissions.inbox-update=true", - "--permissions.label-blocked-read=true", - "--permissions.label-spam-read=true", - "--permissions.label-trash-read=true", - "--permissions.list-entry-create=true", - "--permissions.list-entry-delete=true", - "--permissions.list-entry-read=true", - "--permissions.message-read=true", - "--permissions.message-send=true", - "--permissions.message-update=true", - "--permissions.metrics-read=true", - "--permissions.pod-create=true", - "--permissions.pod-delete=true", - "--permissions.pod-read=true", - "--permissions.thread-delete=true", - "--permissions.thread-read=true", - "--permissions.webhook-create=true", - "--permissions.webhook-delete=true", - "--permissions.webhook-read=true", - "--permissions.webhook-update=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "name: name\n" + - "permissions:\n" + - " api_key_create: true\n" + - " api_key_delete: true\n" + - " api_key_read: true\n" + - " domain_create: true\n" + - " domain_delete: true\n" + - " domain_read: true\n" + - " domain_update: true\n" + - " draft_create: true\n" + - " draft_delete: true\n" + - " draft_read: true\n" + - " draft_send: true\n" + - " draft_update: true\n" + - " inbox_create: true\n" + - " inbox_delete: true\n" + - " inbox_read: true\n" + - " inbox_update: true\n" + - " label_blocked_read: true\n" + - " label_spam_read: true\n" + - " label_trash_read: true\n" + - " list_entry_create: true\n" + - " list_entry_delete: true\n" + - " list_entry_read: true\n" + - " message_read: true\n" + - " message_send: true\n" + - " message_update: true\n" + - " metrics_read: true\n" + - " pod_create: true\n" + - " pod_delete: true\n" + - " pod_read: true\n" + - " thread_delete: true\n" + - " thread_read: true\n" + - " webhook_create: true\n" + - " webhook_delete: true\n" + - " webhook_read: true\n" + - " webhook_update: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "api-keys", "create", - ) - }) -} - -func TestAPIKeysList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "api-keys", "list", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestAPIKeysDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "api-keys", "delete", - "--api-key-id", "api_key_id", - ) - }) -} diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go deleted file mode 100644 index 2e8851d..0000000 --- a/pkg/cmd/cmd.go +++ /dev/null @@ -1,413 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "bytes" - "compress/gzip" - "context" - "fmt" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/agentmail-to/agentmail-cli/internal/autocomplete" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - docs "github.com/urfave/cli-docs/v3" - "github.com/urfave/cli/v3" -) - -var ( - Command *cli.Command - CommandErrorBuffer bytes.Buffer -) - -func init() { - Command = &cli.Command{ - Name: "agentmail", - Usage: "CLI for the agentmail API", - Suggest: true, - Version: Version, - ErrWriter: &CommandErrorBuffer, - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "debug", - Usage: "Enable debug logging", - }, - &cli.StringFlag{ - Name: "base-url", - DefaultText: "url", - Usage: "Override the base URL for API requests", - Validator: func(baseURL string) error { - return ValidateBaseURL(baseURL, "--base-url") - }, - }, - &cli.StringFlag{ - Name: "format", - Usage: "The format for displaying response data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "auto", - Validator: func(format string) error { - if !slices.Contains(OutputFormats, strings.ToLower(format)) { - return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) - } - return nil - }, - }, - &cli.StringFlag{ - Name: "format-error", - Usage: "The format for displaying error data (one of: " + strings.Join(OutputFormats, ", ") + ")", - Value: "auto", - Validator: func(format string) error { - if !slices.Contains(OutputFormats, strings.ToLower(format)) { - return fmt.Errorf("format must be one of: %s", strings.Join(OutputFormats, ", ")) - } - return nil - }, - }, - &cli.StringFlag{ - Name: "transform", - Usage: "The GJSON transformation for data output.", - }, - &cli.StringFlag{ - Name: "transform-error", - Usage: "The GJSON transformation for errors.", - }, - &cli.BoolFlag{ - Name: "raw-output", - Aliases: []string{"r"}, - Usage: "If the result is a string, print it without JSON quotes. This can be useful for making output transforms talk to non-JSON-based systems.", - }, - &requestflag.Flag[string]{ - Name: "api-key", - Sources: cli.EnvVars("AGENTMAIL_API_KEY"), - }, - &cli.StringFlag{ - Name: "environment", - Usage: "Set the environment for API requests", - }, - }, - Commands: []*cli.Command{ - { - Name: "agent", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &agentSignUp, - &agentVerify, - }, - }, - { - Name: "inboxes", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &inboxesCreate, - &inboxesUpdate, - &inboxesList, - &inboxesDelete, - &inboxesGet, - }, - }, - { - Name: "inboxes:drafts", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &inboxesDraftsCreate, - &inboxesDraftsUpdate, - &inboxesDraftsList, - &inboxesDraftsDelete, - &inboxesDraftsGet, - &inboxesDraftsGetAttachment, - &inboxesDraftsSend, - }, - }, - { - Name: "inboxes:messages", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &inboxesMessagesUpdate, - &inboxesMessagesList, - &inboxesMessagesForward, - &inboxesMessagesGet, - &inboxesMessagesGetAttachment, - &inboxesMessagesGetRaw, - &inboxesMessagesReply, - &inboxesMessagesReplyAll, - &inboxesMessagesSearch, - &inboxesMessagesSend, - }, - }, - { - Name: "inboxes:threads", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &inboxesThreadsList, - &inboxesThreadsDelete, - &inboxesThreadsGet, - &inboxesThreadsGetAttachment, - &inboxesThreadsSearch, - }, - }, - { - Name: "inboxes:lists", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &inboxesListsCreate, - &inboxesListsList, - &inboxesListsDelete, - &inboxesListsGet, - }, - }, - { - Name: "inboxes:api-keys", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &inboxesAPIKeysCreate, - &inboxesAPIKeysList, - &inboxesAPIKeysDelete, - }, - }, - { - Name: "pods", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsCreate, - &podsList, - &podsDelete, - &podsGet, - }, - }, - { - Name: "pods:domains", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsDomainsCreate, - &podsDomainsUpdate, - &podsDomainsList, - &podsDomainsDelete, - &podsDomainsGet, - &podsDomainsGetZoneFile, - &podsDomainsVerify, - }, - }, - { - Name: "pods:drafts", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsDraftsList, - &podsDraftsGet, - &podsDraftsGetAttachment, - }, - }, - { - Name: "pods:inboxes", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsInboxesCreate, - &podsInboxesUpdate, - &podsInboxesList, - &podsInboxesDelete, - &podsInboxesGet, - }, - }, - { - Name: "pods:threads", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsThreadsList, - &podsThreadsDelete, - &podsThreadsGet, - &podsThreadsGetAttachment, - &podsThreadsSearch, - }, - }, - { - Name: "pods:lists", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsListsCreate, - &podsListsList, - &podsListsDelete, - &podsListsGet, - }, - }, - { - Name: "pods:api-keys", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &podsAPIKeysCreate, - &podsAPIKeysList, - &podsAPIKeysDelete, - }, - }, - { - Name: "webhooks", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &webhooksCreate, - &webhooksUpdate, - &webhooksList, - &webhooksDelete, - &webhooksGet, - }, - }, - { - Name: "api-keys", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &apiKeysCreate, - &apiKeysList, - &apiKeysDelete, - }, - }, - { - Name: "domains", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &domainsCreate, - &domainsUpdate, - &domainsList, - &domainsDelete, - &domainsGet, - &domainsGetZoneFile, - &domainsVerify, - }, - }, - { - Name: "drafts", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &draftsList, - &draftsGet, - &draftsGetAttachment, - }, - }, - { - Name: "lists", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &listsCreate, - &listsList, - &listsDelete, - &listsGet, - }, - }, - { - Name: "organizations", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &organizationsGet, - }, - }, - { - Name: "threads", - Category: "API RESOURCE", - Suggest: true, - Commands: []*cli.Command{ - &threadsList, - &threadsDelete, - &threadsGet, - &threadsGetAttachment, - &threadsSearch, - }, - }, - { - Name: "@manpages", - Usage: "Generate documentation for 'man'", - UsageText: "agentmail @manpages [-o agentmail.1] [--gzip]", - Hidden: true, - Action: generateManpages, - HideHelpCommand: true, - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "output", - Aliases: []string{"o"}, - Usage: "write manpages to the given folder", - Value: "man", - }, - &cli.BoolFlag{ - Name: "gzip", - Aliases: []string{"z"}, - Usage: "output gzipped manpage files to .gz", - Value: true, - }, - &cli.BoolFlag{ - Name: "text", - Aliases: []string{"z"}, - Usage: "output uncompressed text files", - Value: false, - }, - }, - }, - { - Name: "__complete", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.ExecuteShellCompletion, - }, - { - Name: "@completion", - Hidden: true, - HideHelpCommand: true, - Action: autocomplete.OutputCompletionScript, - }, - }, - HideHelpCommand: true, - } -} - -func generateManpages(ctx context.Context, c *cli.Command) error { - manpage, err := docs.ToManWithSection(Command, 1) - if err != nil { - return err - } - dir := c.String("output") - err = os.MkdirAll(filepath.Join(dir, "man1"), 0755) - if err != nil { - // handle error - } - if c.Bool("text") { - file, err := os.Create(filepath.Join(dir, "man1", "agentmail.1")) - if err != nil { - return err - } - defer file.Close() - if _, err := file.WriteString(manpage); err != nil { - return err - } - } - if c.Bool("gzip") { - file, err := os.Create(filepath.Join(dir, "man1", "agentmail.1.gz")) - if err != nil { - return err - } - defer file.Close() - gzWriter := gzip.NewWriter(file) - defer gzWriter.Close() - _, err = gzWriter.Write([]byte(manpage)) - if err != nil { - return err - } - } - fmt.Printf("Wrote manpages to %s\n", dir) - return nil -} diff --git a/pkg/cmd/cmdutil.go b/pkg/cmd/cmdutil.go deleted file mode 100644 index 644b807..0000000 --- a/pkg/cmd/cmdutil.go +++ /dev/null @@ -1,537 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "log" - "mime" - "net/http" - "net/http/httputil" - "os" - "os/exec" - "os/signal" - "path/filepath" - "strings" - "syscall" - - "github.com/agentmail-to/agentmail-cli/internal/jsonview" - "github.com/agentmail-to/agentmail-go/option" - - "github.com/charmbracelet/x/term" - "github.com/itchyny/json2yaml" - "github.com/muesli/reflow/wrap" - "github.com/tidwall/gjson" - "github.com/tidwall/pretty" - "github.com/urfave/cli/v3" -) - -var OutputFormats = []string{"auto", "explore", "json", "jsonl", "pretty", "raw", "yaml"} - -// ValidateBaseURL checks that a base URL is correctly prefixed with a protocol scheme and produces a better -// error message than the person would see otherwise if it doesn't. -func ValidateBaseURL(value, source string) error { - if value != "" && !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { - return fmt.Errorf("%s %q is missing a scheme (expected http:// or https://)", source, value) - } - return nil -} - -func getDefaultRequestOptions(cmd *cli.Command) []option.RequestOption { - opts := []option.RequestOption{ - option.WithHeader("User-Agent", fmt.Sprintf("Agentmail/CLI %s", Version)), - option.WithHeader("X-Stainless-Lang", "cli"), - option.WithHeader("X-Stainless-Package-Version", Version), - option.WithHeader("X-Stainless-Runtime", "cli"), - option.WithHeader("X-Stainless-CLI-Command", cmd.FullName()), - } - if cmd.IsSet("api-key") { - opts = append(opts, option.WithAPIKey(cmd.String("api-key"))) - } - - // Override base URL if the --base-url flag is provided - if baseURL := cmd.String("base-url"); baseURL != "" { - opts = append(opts, option.WithBaseURL(baseURL)) - } - - // Set environment if the --environment flag is provided - if environment := cmd.String("environment"); environment != "" { - switch environment { - case "production": - opts = append(opts, option.WithEnvironmentProduction()) - case "development": - opts = append(opts, option.WithEnvironmentDevelopment()) - default: - log.Fatalf("Unknown environment: %s. Valid environments are %s", environment, "production, development") - } - } - - return opts -} - -var debugMiddlewareOption = option.WithMiddleware( - func(r *http.Request, mn option.MiddlewareNext) (*http.Response, error) { - logger := log.Default() - - if reqBytes, err := httputil.DumpRequest(r, true); err == nil { - logger.Printf("Request Content:\n%s\n", reqBytes) - } - - resp, err := mn(r) - if err != nil { - return resp, err - } - - if respBytes, err := httputil.DumpResponse(resp, true); err == nil { - logger.Printf("Response Content:\n%s\n", respBytes) - } - - return resp, err - }, -) - -// isInputPiped tries to check for input being piped into the CLI which tells us that we should try to read -// from stdin. This can be a bit tricky in some cases like when an stdin is connected to a pipe but nothing is -// being piped in (this may happen in some environments like Cursor's integration terminal or CI), which is -// why this function is a little more elaborate than it'd be otherwise. -func isInputPiped() bool { - stat, err := os.Stdin.Stat() - if err != nil { - return false - } - - mode := stat.Mode() - - // Regular file (redirect like < file.txt) — only if non-empty. - // - // Notably, on Unix the case like `< /dev/null` is handled below because `/dev/null` is not a regular - // file. On Windows, NUL appears as a regular file with size 0, so it's also handled correctly. - if mode.IsRegular() && stat.Size() > 0 { - return true - } - - // For pipes/sockets (e.g. `echo foo | stainlesscli`), use an OS-specific check to determine whether - // data is actually available. Some environments like Cursor's integrated terminal connect stdin as a - // pipe even when nothing is being piped. - if mode&(os.ModeNamedPipe|os.ModeSocket) != 0 { - // Defined in either cmdutil_unix.go or cmdutil_windows.go. - return isPipedDataAvailableOSSpecific() - } - - return false -} - -func isTerminal(w io.Writer) bool { - switch v := w.(type) { - case *os.File: - return term.IsTerminal(v.Fd()) - default: - return false - } -} - -func streamOutput(label string, generateOutput func(w *os.File) error) error { - // For non-tty output (probably a pipe), write directly to stdout - if !isTerminal(os.Stdout) { - return streamToStdout(generateOutput) - } - - // When streaming output on Unix-like systems, there's a special trick involving creating two socket pairs - // that we prefer because it supports small buffer sizes which results in less pagination per buffer. The - // constructs needed to run it don't exist on Windows builds, so we have this function broken up into - // OS-specific files with conditional build comments. Under Windows (and in case our fancy constructs fail - // on Unix), we fall back to using pipes (`streamToPagerWithPipe`), which are OS agnostic. - // - // Defined in either cmdutil_unix.go or cmdutil_windows.go. - return streamOutputOSSpecific(label, generateOutput) -} - -func streamToPagerWithPipe(label string, generateOutput func(w *os.File) error) error { - r, w, err := os.Pipe() - if err != nil { - return err - } - defer r.Close() - defer w.Close() - - pagerProgram := os.Getenv("PAGER") - if pagerProgram == "" { - pagerProgram = "less" - } - - if _, err := exec.LookPath(pagerProgram); err != nil { - return err - } - - cmd := exec.Command(pagerProgram) - cmd.Stdin = r - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = append(os.Environ(), - "LESS=-X -r -P "+label, - "MORE=-r -P "+label, - ) - - if err := cmd.Start(); err != nil { - return err - } - - if err := r.Close(); err != nil { - return err - } - - // If we would be streaming to a terminal and aren't forcing color one way - // or the other, we should configure things to use color so the pager gets - // colorized input. - if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { - os.Setenv("FORCE_COLOR", "1") - } - - if err := generateOutput(w); err != nil && !strings.Contains(err.Error(), "broken pipe") { - return err - } - - w.Close() - return cmd.Wait() -} - -func streamToStdout(generateOutput func(w *os.File) error) error { - signal.Ignore(syscall.SIGPIPE) - err := generateOutput(os.Stdout) - if err != nil && strings.Contains(err.Error(), "broken pipe") { - return nil - } - return err -} - -// writeBinaryResponse writes a binary response to stdout or a file. -// -// Takes in a stdout reference so we can test this function without overriding os.Stdout in tests. -func writeBinaryResponse(response *http.Response, stdout io.Writer, outfile string) (string, error) { - defer response.Body.Close() - body, err := io.ReadAll(response.Body) - if err != nil { - return "", err - } - switch outfile { - case "-", "/dev/stdout": - _, err := stdout.Write(body) - return "", err - case "": - // If output file is unspecified, then print to stdout for plain text or - // if stdout is not a terminal: - if !isTerminal(os.Stdout) || isUTF8TextFile(body) { - _, err := stdout.Write(body) - return "", err - } - - // If response has a suggested filename in the content-disposition - // header, then use that (with an optional suffix to ensure uniqueness): - file, err := createDownloadFile(response, body) - if err != nil { - return "", err - } - defer file.Close() - if _, err := file.Write(body); err != nil { - return "", err - } - return fmt.Sprintf("Wrote output to: %s", file.Name()), nil - default: - if err := os.WriteFile(outfile, body, 0644); err != nil { - return "", err - } - return fmt.Sprintf("Wrote output to: %s", outfile), nil - } -} - -// Return a writable file handle to a new file, which attempts to choose a good filename -// based on the Content-Disposition header or sniffing the MIME filetype of the response. -func createDownloadFile(response *http.Response, data []byte) (*os.File, error) { - filename := "file" - // If the header provided an output filename, use that - disp := response.Header.Get("Content-Disposition") - _, params, err := mime.ParseMediaType(disp) - if err == nil { - if dispFilename, ok := params["filename"]; ok { - // Only use the last path component to prevent directory traversal - filename = filepath.Base(dispFilename) - // Try to create the file with exclusive flag to avoid race conditions - file, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) - if err == nil { - return file, nil - } - } - } - - // If file already exists, create a unique filename using CreateTemp - ext := filepath.Ext(filename) - if ext == "" { - ext = guessExtension(data) - } - base := strings.TrimSuffix(filename, ext) - return os.CreateTemp(".", base+"-*"+ext) -} - -func guessExtension(data []byte) string { - ct := http.DetectContentType(data) - - // Prefer common extensions over obscure ones - switch ct { - case "application/gzip": - return ".gz" - case "application/pdf": - return ".pdf" - case "application/zip": - return ".zip" - case "audio/mpeg": - return ".mp3" - case "image/bmp": - return ".bmp" - case "image/gif": - return ".gif" - case "image/jpeg": - return ".jpg" - case "image/png": - return ".png" - case "image/webp": - return ".webp" - case "video/mp4": - return ".mp4" - } - - exts, err := mime.ExtensionsByType(ct) - if err == nil && len(exts) > 0 { - return exts[0] - } else if isUTF8TextFile(data) { - return ".txt" - } else { - return ".bin" - } -} - -func shouldUseColors(w io.Writer) bool { - force, ok := os.LookupEnv("FORCE_COLOR") - if ok { - if force == "1" { - return true - } - if force == "0" { - return false - } - } - return isTerminal(w) -} - -func formatJSON(res gjson.Result, opts ShowJSONOpts) ([]byte, error) { - if opts.Transform != "" { - transformed := res.Get(opts.Transform) - if transformed.Exists() { - res = transformed - } - } - // Modeled after `jq -r` (`--raw-output`): if the result is a string, print it without JSON quotes so that - // it's easier to pipe into other programs. - if opts.RawOutput && res.Type == gjson.String { - return []byte(res.Str + "\n"), nil - } - switch strings.ToLower(opts.Format) { - case "auto": - autoOpts := opts - autoOpts.Format = "json" - autoOpts.Transform = "" - return formatJSON(res, autoOpts) - case "pretty": - return []byte(jsonview.RenderJSON(opts.Title, res) + "\n"), nil - case "json": - prettyJSON := pretty.Pretty([]byte(res.Raw)) - if shouldUseColors(opts.Stdout) { - return pretty.Color(prettyJSON, pretty.TerminalStyle), nil - } else { - return prettyJSON, nil - } - case "jsonl": - // @ugly is gjson syntax for "no whitespace", so it fits on one line - oneLineJSON := res.Get("@ugly").Raw - if shouldUseColors(opts.Stdout) { - bytes := append(pretty.Color([]byte(oneLineJSON), pretty.TerminalStyle), '\n') - return bytes, nil - } else { - return []byte(oneLineJSON + "\n"), nil - } - case "raw": - return []byte(res.Raw + "\n"), nil - case "yaml": - input := strings.NewReader(res.Raw) - var yaml strings.Builder - if err := json2yaml.Convert(&yaml, input); err != nil { - return nil, err - } - _, err := opts.Stdout.Write([]byte(yaml.String())) - return nil, err - default: - return nil, fmt.Errorf("Invalid format: %s, valid formats are: %s", opts.Format, strings.Join(OutputFormats, ", ")) - } -} - -const warningExploreNotSupported = "Warning: Output format 'explore' not supported for non-terminal output; falling back to 'json'\n" - -// ShowJSONOpts configures how JSON output is displayed. -type ShowJSONOpts struct { - ExplicitFormat bool // true if the user explicitly passed --format - Format string // output format (auto, explore, json, jsonl, pretty, raw, yaml) - RawOutput bool // like jq -r: print strings without JSON quotes - Stderr io.Writer // stderr for warnings; injectable for testing; defaults to os.Stderr - Stdout *os.File // stdout (or pager); injectable for testing; defaults to os.Stdout - Title string // display title - Transform string // GJSON path to extract before displaying -} - -func (o *ShowJSONOpts) setDefaults() { - if o.Stderr == nil { - o.Stderr = os.Stderr - } - if o.Stdout == nil { - o.Stdout = os.Stdout - } -} - -// ShowJSON displays a single JSON result to the user. -func ShowJSON(res gjson.Result, opts ShowJSONOpts) error { - opts.setDefaults() - - switch strings.ToLower(opts.Format) { - case "auto": - autoOpts := opts - autoOpts.Format = "json" - return ShowJSON(res, autoOpts) - case "explore": - if !isTerminal(opts.Stdout) { - if opts.ExplicitFormat { - fmt.Fprint(opts.Stderr, warningExploreNotSupported) - } - jsonOpts := opts - jsonOpts.Format = "json" - return ShowJSON(res, jsonOpts) - } - if opts.Transform != "" { - transformed := res.Get(opts.Transform) - if transformed.Exists() { - res = transformed - } - } - return jsonview.ExploreJSON(opts.Title, res) - default: - bytes, err := formatJSON(res, opts) - if err != nil { - return err - } - - _, err = opts.Stdout.Write(bytes) - return err - } -} - -// Get the number of lines that would be output by writing the data to the terminal -func countTerminalLines(data []byte, terminalWidth int) int { - return bytes.Count([]byte(wrap.String(string(data), terminalWidth)), []byte("\n")) -} - -type hasRawJSON interface { - RawJSON() string -} - -// ShowJSONIterator displays an iterator of values to the user. Use itemsToDisplay = -1 for no limit. -func ShowJSONIterator[T any](iter jsonview.Iterator[T], itemsToDisplay int64, opts ShowJSONOpts) error { - opts.setDefaults() - - if opts.Format == "explore" { - if isTerminal(opts.Stdout) { - return jsonview.ExploreJSONStream(opts.Title, iter) - } - if opts.ExplicitFormat { - fmt.Fprint(opts.Stderr, warningExploreNotSupported) - } - opts.Format = "json" - } - - terminalWidth, terminalHeight, err := term.GetSize(os.Stdout.Fd()) - if err != nil { - terminalWidth = 100 - terminalHeight = 40 - } - - // Decide whether or not to use a pager based on whether it's a short output or a long output - usePager := false - output := []byte{} - numberOfNewlines := 0 - // -1 is used to signal no limit of items to display - for itemsToDisplay != 0 && iter.Next() { - item := iter.Current() - var obj gjson.Result - if hasRaw, ok := any(item).(hasRawJSON); ok { - obj = gjson.Parse(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return err - } - obj = gjson.ParseBytes(jsonData) - } - json, err := formatJSON(obj, opts) - if err != nil { - return err - } - - output = append(output, json...) - itemsToDisplay -= 1 - numberOfNewlines += countTerminalLines(json, terminalWidth) - - // If the output won't fit in the terminal window, stream it to a pager - if numberOfNewlines >= terminalHeight-3 { - usePager = true - break - } - } - - if !usePager { - _, err := opts.Stdout.Write(output) - if err != nil { - return err - } - - return iter.Err() - } - - return streamOutput(opts.Title, func(pager *os.File) error { - _, err := pager.Write(output) - if err != nil { - return err - } - - pagerOpts := opts - pagerOpts.Stdout = pager - - for iter.Next() { - if itemsToDisplay == 0 { - break - } - item := iter.Current() - var obj gjson.Result - if hasRaw, ok := any(item).(hasRawJSON); ok { - obj = gjson.Parse(hasRaw.RawJSON()) - } else { - jsonData, err := json.Marshal(item) - if err != nil { - return err - } - obj = gjson.ParseBytes(jsonData) - } - if err := ShowJSON(obj, pagerOpts); err != nil { - return err - } - itemsToDisplay -= 1 - } - return iter.Err() - }) -} diff --git a/pkg/cmd/cmdutil_test.go b/pkg/cmd/cmdutil_test.go deleted file mode 100644 index 2388ce6..0000000 --- a/pkg/cmd/cmdutil_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package cmd - -import ( - "bytes" - "io" - "net/http" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" - - "github.com/agentmail-to/agentmail-cli/internal/jsonview" -) - -func TestStreamOutput(t *testing.T) { - t.Setenv("PAGER", "cat") - err := streamOutput("stream test", func(w *os.File) error { - _, writeErr := w.WriteString("Hello world\n") - return writeErr - }) - if err != nil { - t.Errorf("streamOutput failed: %v", err) - } -} - -func TestWriteBinaryResponse(t *testing.T) { - t.Run("write to explicit file", func(t *testing.T) { - tmpDir := t.TempDir() - outfile := tmpDir + "/output.txt" - body := []byte("test content") - resp := &http.Response{ - Body: io.NopCloser(bytes.NewReader(body)), - } - - msg, err := writeBinaryResponse(resp, os.Stdout, outfile) - - require.NoError(t, err) - assert.Contains(t, msg, outfile) - - content, err := os.ReadFile(outfile) - require.NoError(t, err) - assert.Equal(t, body, content) - }) - - t.Run("write to stdout", func(t *testing.T) { - t.Parallel() - - var buf bytes.Buffer - body := []byte("stdout content") - resp := &http.Response{ - Body: io.NopCloser(bytes.NewReader(body)), - } - msg, err := writeBinaryResponse(resp, &buf, "-") - - require.NoError(t, err) - assert.Empty(t, msg) - assert.Equal(t, body, buf.Bytes()) - }) -} - -func TestCreateDownloadFile(t *testing.T) { - t.Run("creates file with filename from header", func(t *testing.T) { - t.Chdir(t.TempDir()) - - resp := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="test.txt"`}, - }, - } - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Equal(t, "test.txt", filepath.Base(file.Name())) - - // Create a second file with the same name to ensure it doesn't clobber the first - resp2 := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="test.txt"`}, - }, - } - file2, err := createDownloadFile(resp2, []byte("second content")) - require.NoError(t, err) - defer file2.Close() - assert.NotEqual(t, file.Name(), file2.Name(), "second file should have a different name") - assert.Contains(t, filepath.Base(file2.Name()), "test") - }) - - t.Run("creates temp file when no header", func(t *testing.T) { - t.Chdir(t.TempDir()) - - resp := &http.Response{Header: http.Header{}} - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Contains(t, filepath.Base(file.Name()), "file-") - }) - - t.Run("prevents directory traversal", func(t *testing.T) { - t.Chdir(t.TempDir()) - - resp := &http.Response{ - Header: http.Header{ - "Content-Disposition": []string{`attachment; filename="../../../etc/passwd"`}, - }, - } - file, err := createDownloadFile(resp, []byte("test content")) - require.NoError(t, err) - defer file.Close() - assert.Equal(t, "passwd", filepath.Base(file.Name())) - }) -} - -func TestValidateBaseURL(t *testing.T) { - t.Parallel() - - t.Run("ValidHTTPS", func(t *testing.T) { - t.Parallel() - - require.NoError(t, ValidateBaseURL("https://api.example.com", "--base-url")) - }) - - t.Run("ValidHTTP", func(t *testing.T) { - t.Parallel() - - require.NoError(t, ValidateBaseURL("http://localhost:8080", "--base-url")) - }) - - t.Run("Empty", func(t *testing.T) { - t.Parallel() - - require.NoError(t, ValidateBaseURL("", "MY_BASE_URL")) - }) - - t.Run("MissingScheme", func(t *testing.T) { - t.Parallel() - - err := ValidateBaseURL("localhost:8080", "MY_BASE_URL") - require.Error(t, err) - assert.Contains(t, err.Error(), "MY_BASE_URL") - assert.Contains(t, err.Error(), "missing a scheme") - }) - - t.Run("HostOnly", func(t *testing.T) { - t.Parallel() - - err := ValidateBaseURL("api.example.com", "--base-url") - require.Error(t, err) - assert.Contains(t, err.Error(), "--base-url") - }) -} - -func TestFormatJSON(t *testing.T) { - t.Parallel() - - t.Run("RawWithTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "id"}) - require.NoError(t, err) - require.Equal(t, `"abc123"`+"\n", string(formatted)) - }) - - t.Run("RawWithoutTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout}) - require.NoError(t, err) - require.Equal(t, `{"id":"abc123","name":"test"}`+"\n", string(formatted)) - }) - - t.Run("RawWithNestedTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"data":{"items":[1,2,3]}}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "data.items"}) - require.NoError(t, err) - require.Equal(t, "[1,2,3]\n", string(formatted)) - }) - - t.Run("RawWithNonexistentTransform", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "missing"}) - require.NoError(t, err) - // Transform path doesn't exist, so original result is returned - require.Equal(t, `{"id":"abc123"}`+"\n", string(formatted)) - }) - - t.Run("RawOutputString", func(t *testing.T) { - t.Parallel() - - res := gjson.Parse(`{"id":"abc123","name":"test"}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "json", Stdout: os.Stdout, Transform: "id", RawOutput: true}) - require.NoError(t, err) - require.Equal(t, "abc123\n", string(formatted)) - }) - - t.Run("RawOutputNonString", func(t *testing.T) { - t.Parallel() - - // --raw-output has no effect on non-string values - res := gjson.Parse(`{"count":42}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "count", RawOutput: true}) - require.NoError(t, err) - require.Equal(t, "42\n", string(formatted)) - }) - - t.Run("RawOutputObject", func(t *testing.T) { - t.Parallel() - - // --raw-output has no effect on objects - res := gjson.Parse(`{"nested":{"a":1}}`) - formatted, err := formatJSON(res, ShowJSONOpts{Format: "raw", Stdout: os.Stdout, Transform: "nested", RawOutput: true}) - require.NoError(t, err) - require.Equal(t, `{"a":1}`+"\n", string(formatted)) - }) -} - -func TestShowJSONIterator(t *testing.T) { - t.Parallel() - - t.Run("RawMultipleItems", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc", "name": "first"}, - {"id": "def", "name": "second"}, - }} - captured := captureShowJSONIterator(t, iter, "raw", "", -1) - assert.Equal(t, `{"id":"abc","name":"first"}`+"\n"+`{"id":"def","name":"second"}`+"\n", captured) - }) - - t.Run("RawWithTransform", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc", "name": "first"}, - {"id": "def", "name": "second"}, - }} - captured := captureShowJSONIterator(t, iter, "raw", "id", -1) - assert.Equal(t, `"abc"`+"\n"+`"def"`+"\n", captured) - }) - - t.Run("LimitItems", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc"}, - {"id": "def"}, - {"id": "ghi"}, - }} - captured := captureShowJSONIterator(t, iter, "raw", "", 2) - assert.Equal(t, `{"id":"abc"}`+"\n"+`{"id":"def"}`+"\n", captured) - }) -} - -func TestExploreFallback(t *testing.T) { - t.Parallel() - - t.Run("ShowJSONFallsBackToJsonOnNonTTY", func(t *testing.T) { - t.Parallel() - - // os.Pipe() produces a *os.File that isn't a terminal, so explore should fall back. - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - var stderr bytes.Buffer - res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(res, ShowJSONOpts{ - Format: "explore", - Stderr: &stderr, - Stdout: w, - Title: "test", - }) - w.Close() - require.NoError(t, err) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - assert.Contains(t, buf.String(), `"id"`) - assert.Contains(t, buf.String(), `"abc"`) - }) - - t.Run("ShowJSONIteratorFallsBackToJsonOnNonTTY", func(t *testing.T) { - t.Parallel() - - iter := &sliceIterator[map[string]any]{items: []map[string]any{ - {"id": "abc"}, - }} - captured := captureShowJSONIterator(t, iter, "explore", "", -1) - assert.Contains(t, captured, `"id"`) - assert.Contains(t, captured, `"abc"`) - }) - - t.Run("ShowJSONWarnsWhenExplicitFormatOnNonTTY", func(t *testing.T) { - t.Parallel() - - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - var stderr bytes.Buffer - res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(res, ShowJSONOpts{ - ExplicitFormat: true, - Format: "explore", - Stderr: &stderr, - Stdout: w, - Title: "test", - }) - w.Close() - require.NoError(t, err) - - assert.Equal(t, warningExploreNotSupported, stderr.String()) - }) - - t.Run("ShowJSONSilentWhenDefaultFormatOnNonTTY", func(t *testing.T) { - t.Parallel() - - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - var stderr bytes.Buffer - res := gjson.Parse(`{"id":"abc"}`) - err = ShowJSON(res, ShowJSONOpts{ - Format: "explore", - Stderr: &stderr, - Stdout: w, - Title: "test", - }) - w.Close() - require.NoError(t, err) - - assert.Empty(t, stderr.String(), "no warning expected when format was not explicit") - }) -} - -// sliceIterator is a simple iterator over a slice for testing. -type sliceIterator[T any] struct { - index int - items []T -} - -func (it *sliceIterator[T]) Next() bool { - it.index++ - return it.index <= len(it.items) -} - -func (it *sliceIterator[T]) Current() T { - return it.items[it.index-1] -} - -func (it *sliceIterator[T]) Err() error { - return nil -} - -var _ jsonview.Iterator[any] = (*sliceIterator[any])(nil) - -// captureShowJSONIterator runs ShowJSONIterator and captures the output written to a file. -func captureShowJSONIterator[T any](t *testing.T, iter jsonview.Iterator[T], format, transform string, itemsToDisplay int64) string { - t.Helper() - - r, w, err := os.Pipe() - require.NoError(t, err) - defer r.Close() - - err = ShowJSONIterator(iter, itemsToDisplay, ShowJSONOpts{ - Format: format, - Stderr: io.Discard, - Stdout: w, - Title: "test", - Transform: transform, - }) - w.Close() - require.NoError(t, err) - - var buf bytes.Buffer - _, _ = buf.ReadFrom(r) - return buf.String() -} diff --git a/pkg/cmd/cmdutil_unix.go b/pkg/cmd/cmdutil_unix.go deleted file mode 100644 index edefcd7..0000000 --- a/pkg/cmd/cmdutil_unix.go +++ /dev/null @@ -1,127 +0,0 @@ -//go:build !windows - -package cmd - -import ( - "fmt" - "os" - "os/exec" - "strings" - "syscall" - - "golang.org/x/sys/unix" -) - -func isPipedDataAvailableOSSpecific() bool { - // Try to determine if there's non-empty data being piped into the command by polling for data for a short - // amount of time. This is necessary because some environments (e.g. Cursor's integrated terminal) connect - // stdin as a pipe even when nothing is being piped, which would cause the command to block indefinitely - // waiting for input that will never come. The 10 ms timeout is arbitrary -- designed to be long enough to - // allow data to be detected, but short enough that it shouldn't cause a noticeable delay in command runs. - fds := []unix.PollFd{{Fd: int32(os.Stdin.Fd()), Events: unix.POLLIN}} - n, _ := unix.Poll(fds, 10 /* ms */) - return n > 0 -} - -func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { - // Try to use socket pair for better buffer control - pagerInput, pid, err := openSocketPairPager(label) - if err != nil || pagerInput == nil { - // Fall back to pipe if socket setup fails - return streamToPagerWithPipe(label, generateOutput) - } - defer pagerInput.Close() - - // If we would be streaming to a terminal and aren't forcing color one way - // or the other, we should configure things to use color so the pager gets - // colorized input. - if isTerminal(os.Stdout) && os.Getenv("FORCE_COLOR") == "" { - os.Setenv("FORCE_COLOR", "1") - } - - // If the pager exits before reading all input, then generateOutput() will - // produce a broken pipe error, which is fine and we don't want to propagate it. - if err := generateOutput(pagerInput); err != nil && - !strings.Contains(err.Error(), "broken pipe") { - return err - } - - // Close the file NOW before we wait for the child process to terminate. - // This way, the child will receive the end-of-file signal and know that - // there is no more input. Otherwise the child process may block - // indefinitely waiting for another line (this can happen when streaming - // less than a screenful of data to a pager). - pagerInput.Close() - - // Wait for child process to exit - var wstatus syscall.WaitStatus - _, err = syscall.Wait4(pid, &wstatus, 0, nil) - if wstatus.ExitStatus() != 0 { - return fmt.Errorf("Pager exited with non-zero exit status: %d", wstatus.ExitStatus()) - } - return err -} - -func openSocketPairPager(label string) (*os.File, int, error) { - fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) - if err != nil { - return nil, 0, err - } - - // The child file descriptor will be sent to the child process through - // ProcAttr and ForkExec(), while the parent process will always close the - // child file descriptor. - // The parent file descriptor will be wrapped in an os.File wrapper and - // returned from this function, or closed if something goes wrong. - parentFd, childFd := fds[0], fds[1] - defer unix.Close(childFd) - - // Use small buffer sizes so we don't ask the server for more paginated - // values than we actually need. - if err := unix.SetsockoptInt(parentFd, unix.SOL_SOCKET, unix.SO_SNDBUF, 128); err != nil { - unix.Close(parentFd) - return nil, 0, err - } - if err := unix.SetsockoptInt(childFd, unix.SOL_SOCKET, unix.SO_RCVBUF, 128); err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - // Set CLOEXEC on the parent file descriptor so it doesn't leak to child - syscall.CloseOnExec(parentFd) - - parentConn := os.NewFile(uintptr(parentFd), "parent-socket") - - pagerProgram := os.Getenv("PAGER") - if pagerProgram == "" { - pagerProgram = "less" - } - - pagerPath, err := exec.LookPath(pagerProgram) - if err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - env := os.Environ() - env = append(env, "LESS=-r -P "+label) - env = append(env, "MORE=-r -P "+label) - - procAttr := &syscall.ProcAttr{ - Dir: "", - Env: env, - Files: []uintptr{ - uintptr(childFd), // stdin (fd 0) - uintptr(syscall.Stdout), // stdout (fd 1) - uintptr(syscall.Stderr), // stderr (fd 2) - }, - } - - pid, err := syscall.ForkExec(pagerPath, []string{pagerProgram}, procAttr) - if err != nil { - unix.Close(parentFd) - return nil, 0, err - } - - return parentConn, pid, nil -} diff --git a/pkg/cmd/cmdutil_windows.go b/pkg/cmd/cmdutil_windows.go deleted file mode 100644 index 49b025e..0000000 --- a/pkg/cmd/cmdutil_windows.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build windows - -package cmd - -import ( - "os" - "syscall" - "unsafe" -) - -var ( - kernel32 = syscall.NewLazyDLL("kernel32.dll") - procPeekNamedPipe = kernel32.NewProc("PeekNamedPipe") -) - -func isPipedDataAvailableOSSpecific() bool { - // On Windows, unix.Poll is not available. Use PeekNamedPipe to check if data is available - // on the pipe without consuming it. - var available uint32 - r, _, _ := procPeekNamedPipe.Call( - os.Stdin.Fd(), - 0, - 0, - 0, - uintptr(unsafe.Pointer(&available)), - 0, - ) - return r != 0 && available > 0 -} - -func streamOutputOSSpecific(label string, generateOutput func(w *os.File) error) error { - // We have a trick with sockets that we use when possible on Unix-like systems. Those APIs aren't - // available on Windows, so we fall back to using pipes. - return streamToPagerWithPipe(label, generateOutput) -} diff --git a/pkg/cmd/domain.go b/pkg/cmd/domain.go deleted file mode 100644 index 816f1ce..0000000 --- a/pkg/cmd/domain.go +++ /dev/null @@ -1,404 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var domainsCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "domain", - Usage: "The name of the domain (e.g., `example.com`).", - Required: true, - BodyPath: "domain", - }, - &requestflag.Flag[*bool]{ - Name: "feedback-enabled", - Usage: "Bounce and complaint notifications are sent to your inboxes.", - BodyPath: "feedback_enabled", - }, - &requestflag.Flag[*bool]{ - Name: "subdomains-enabled", - Usage: "Allow inboxes on any subdomain of this domain. Adds a required wildcard MX\nrecord (`*.`) to `records`.", - BodyPath: "subdomains_enabled", - }, - }, - Action: handleDomainsCreate, - HideHelpCommand: true, -} - -var domainsUpdate = cli.Command{ - Name: "update", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - &requestflag.Flag[*bool]{ - Name: "feedback-enabled", - Usage: "Bounce and complaint notifications are sent to your inboxes.", - BodyPath: "feedback_enabled", - }, - &requestflag.Flag[*bool]{ - Name: "subdomains-enabled", - Usage: "Allow inboxes on any subdomain of this domain. Adds a required wildcard MX\nrecord (`*.`) to `records`.", - BodyPath: "subdomains_enabled", - }, - }, - Action: handleDomainsUpdate, - HideHelpCommand: true, -} - -var domainsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleDomainsList, - HideHelpCommand: true, -} - -var domainsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handleDomainsDelete, - HideHelpCommand: true, -} - -var domainsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handleDomainsGet, - HideHelpCommand: true, -} - -var domainsGetZoneFile = cli.Command{ - Name: "get-zone-file", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handleDomainsGetZoneFile, - HideHelpCommand: true, -} - -var domainsVerify = cli.Command{ - Name: "verify", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handleDomainsVerify, - HideHelpCommand: true, -} - -func handleDomainsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.DomainNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Domains.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "domains create", - Transform: transform, - }) -} - -func handleDomainsUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.DomainUpdateParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Domains.Update( - ctx, - cmd.Value("domain-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "domains update", - Transform: transform, - }) -} - -func handleDomainsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.DomainListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Domains.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "domains list", - Transform: transform, - }) -} - -func handleDomainsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Domains.Delete(ctx, cmd.Value("domain-id").(string), options...) -} - -func handleDomainsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Domains.Get(ctx, cmd.Value("domain-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "domains get", - Transform: transform, - }) -} - -func handleDomainsGetZoneFile(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Domains.GetZoneFile(ctx, cmd.Value("domain-id").(string), options...) -} - -func handleDomainsVerify(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Domains.Verify(ctx, cmd.Value("domain-id").(string), options...) -} diff --git a/pkg/cmd/domain_test.go b/pkg/cmd/domain_test.go deleted file mode 100644 index c7d378f..0000000 --- a/pkg/cmd/domain_test.go +++ /dev/null @@ -1,125 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestDomainsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "create", - "--domain", "domain", - "--feedback-enabled=true", - "--subdomains-enabled=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "domain: domain\n" + - "feedback_enabled: true\n" + - "subdomains_enabled: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "domains", "create", - ) - }) -} - -func TestDomainsUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "update", - "--domain-id", "domain_id", - "--feedback-enabled=true", - "--subdomains-enabled=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "feedback_enabled: true\n" + - "subdomains_enabled: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "domains", "update", - "--domain-id", "domain_id", - ) - }) -} - -func TestDomainsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "list", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestDomainsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "delete", - "--domain-id", "domain_id", - ) - }) -} - -func TestDomainsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "get", - "--domain-id", "domain_id", - ) - }) -} - -func TestDomainsGetZoneFile(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "get-zone-file", - "--domain-id", "domain_id", - ) - }) -} - -func TestDomainsVerify(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "domains", "verify", - "--domain-id", "domain_id", - ) - }) -} diff --git a/pkg/cmd/draft.go b/pkg/cmd/draft.go deleted file mode 100644 index b105d8a..0000000 --- a/pkg/cmd/draft.go +++ /dev/null @@ -1,227 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var draftsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleDraftsList, - HideHelpCommand: true, -} - -var draftsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - }, - Action: handleDraftsGet, - HideHelpCommand: true, -} - -var draftsGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handleDraftsGetAttachment, - HideHelpCommand: true, -} - -func handleDraftsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.DraftListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Drafts.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "drafts list", - Transform: transform, - }) -} - -func handleDraftsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { - cmd.Set("draft-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Drafts.Get(ctx, cmd.Value("draft-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "drafts get", - Transform: transform, - }) -} - -func handleDraftsGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.DraftGetAttachmentParams{ - DraftID: cmd.Value("draft-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Drafts.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "drafts get-attachment", - Transform: transform, - }) -} diff --git a/pkg/cmd/draft_test.go b/pkg/cmd/draft_test.go deleted file mode 100644 index bed16cb..0000000 --- a/pkg/cmd/draft_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestDraftsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "drafts", "list", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestDraftsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "drafts", "get", - "--draft-id", "draft_id", - ) - }) -} - -func TestDraftsGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "drafts", "get-attachment", - "--draft-id", "draft_id", - "--attachment-id", "attachment_id", - ) - }) -} diff --git a/pkg/cmd/flagoptions.go b/pkg/cmd/flagoptions.go deleted file mode 100644 index db43a15..0000000 --- a/pkg/cmd/flagoptions.go +++ /dev/null @@ -1,692 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "maps" - "mime" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "reflect" - "strings" - "unicode/utf8" - - "github.com/agentmail-to/agentmail-cli/internal/apiform" - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/debugmiddleware" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go/option" - - "github.com/goccy/go-yaml" - "github.com/urfave/cli/v3" -) - -type BodyContentType int - -const ( - EmptyBody BodyContentType = iota - MultipartFormEncoded - ApplicationJSON - ApplicationOctetStream -) - -type FileEmbedStyle int - -const ( - // EmbedText reads referenced files fully into memory and substitutes the file's contents back into the - // value as a string. Binary files are base64-encoded. Used for JSON request bodies and for headers and - // query parameters, where the file contents need to be serialized inline. - EmbedText FileEmbedStyle = iota - - // EmbedIOReader replaces file references with an io.Reader that streams the file's contents. Used for - // `multipart/form-data` and `application/octet-stream` request bodies, where files are uploaded as binary - // parts rather than embedded into a text value. - EmbedIOReader -) - -// onceStdinReader wraps an io.Reader that can only be consumed once, used to ensure stdin is read by at most -// one parameter (or only for a body root parameter or only for YAML parameter input). If reason is set, stdin -// is unavailable and read() returns an error explaining why. -type onceStdinReader struct { - stdinReader io.Reader - failureReason string -} - -func (o *onceStdinReader) read() (io.Reader, error) { - if o.failureReason != "" { - return nil, fmt.Errorf("cannot read from stdin: %s", o.failureReason) - } - if o.stdinReader == nil { - return nil, fmt.Errorf("stdin has already been read by another parameter; it can only be read once") - } - r := o.stdinReader - o.stdinReader = nil - return r, nil -} - -func (o *onceStdinReader) readAll() ([]byte, error) { - r, err := o.read() - if err != nil { - return nil, err - } - return io.ReadAll(r) -} - -func isStdinPath(s string) bool { - switch s { - case "-", "/dev/fd/0", "/dev/stdin": - return true - } - return false -} - -func embedFiles(obj any, embedStyle FileEmbedStyle, stdin *onceStdinReader) (any, error) { - if obj == nil { - return obj, nil - } - v := reflect.ValueOf(obj) - result, err := embedFilesValue(v, embedStyle, stdin) - if err != nil { - return nil, err - } - return result.Interface(), nil -} - -// Replace "@file.txt" with the file's contents inside a value -func embedFilesValue(v reflect.Value, embedStyle FileEmbedStyle, stdin *onceStdinReader) (reflect.Value, error) { - // Unwrap interface values to get the concrete type - if v.Kind() == reflect.Interface { - if v.IsNil() { - return v, nil - } - v = v.Elem() - } - - switch v.Kind() { - case reflect.Map: - if v.Len() == 0 { - return v, nil - } - // Always create map[string]any to handle potential type changes when embedding files - result := reflect.MakeMap(reflect.TypeOf(map[string]any{})) - - iter := v.MapRange() - for iter.Next() { - key := iter.Key() - val := iter.Value() - newVal, err := embedFilesValue(val, embedStyle, stdin) - if err != nil { - return reflect.Value{}, err - } - result.SetMapIndex(key, newVal) - } - return result, nil - - case reflect.Slice, reflect.Array: - if v.Len() == 0 { - return v, nil - } - // Use `[]any` to allow for types to change when embedding files - result := reflect.MakeSlice(reflect.TypeOf([]any{}), v.Len(), v.Len()) - for i := 0; i < v.Len(); i++ { - newVal, err := embedFilesValue(v.Index(i), embedStyle, stdin) - if err != nil { - return reflect.Value{}, err - } - result.Index(i).Set(newVal) - } - return result, nil - - case reflect.String: - // FilePathValue is always treated as a file path without needing the "@" prefix. - // These only appear on binary upload parameters (multipart/octet-stream), which - // always use EmbedIOReader. - if v.Type() == reflect.TypeOf(FilePathValue("")) { - s := v.String() - if s == "" { - return v, nil - } - if embedStyle == EmbedIOReader { - if isStdinPath(s) { - r, err := stdin.read() - if err != nil { - return v, err - } - return reflect.ValueOf(io.NopCloser(r)), nil - } - upload, err := openFileUpload(s) - if err != nil { - return v, err - } - return reflect.ValueOf(upload), nil - } - if isStdinPath(s) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } - content, err := os.ReadFile(s) - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } - - s := v.String() - if literal, ok := strings.CutPrefix(s, "\\@"); ok { - // Allow for escaped @ signs if you don't want them to be treated as files - return reflect.ValueOf("@" + literal), nil - } - - if embedStyle == EmbedText { - if filename, ok := strings.CutPrefix(s, "@data://"); ok { - // The "@data://" prefix is for files you explicitly want to upload - // as base64-encoded (even if the file itself is plain text) - if isStdinPath(filename) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - content, err := os.ReadFile(filename) - if err != nil { - return v, err - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } else if filename, ok := strings.CutPrefix(s, "@file://"); ok { - // The "@file://" prefix is for files that you explicitly want to - // upload as a string literal with backslash escapes (not base64 - // encoded) - if isStdinPath(filename) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } - content, err := os.ReadFile(filename) - if err != nil { - return v, err - } - return reflect.ValueOf(string(content)), nil - } else if filename, ok := strings.CutPrefix(s, "@"); ok { - if isStdinPath(filename) { - content, err := stdin.readAll() - if err != nil { - return v, err - } - if isUTF8TextFile(content) { - return reflect.ValueOf(string(content)), nil - } - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - content, err := os.ReadFile(filename) - if err != nil { - // If the string is "@username", it's probably supposed to be a - // string literal and not a file reference. However, if the - // string looks like "@file.txt" or "@/tmp/file", then it's - // probably supposed to be a file. - probablyFile := strings.Contains(filename, ".") || strings.Contains(filename, "/") - if probablyFile { - // Give a useful error message if the user tried to upload a - // file, but the file couldn't be read (e.g. mistyped - // filename or permission error) - return v, err - } - // Fall back to the raw value if the user provided something - // like "@username" that's not intended to be a file. - return v, nil - } - // If the file looks like a plain text UTF8 file format, then use the contents directly. - if isUTF8TextFile(content) { - return reflect.ValueOf(string(content)), nil - } - // Otherwise it's a binary file, so encode it with base64 - return reflect.ValueOf(base64.StdEncoding.EncodeToString(content)), nil - } - } else { - if filename, ok := strings.CutPrefix(s, "@"); ok { - // Behavior is the same for @file, @data://file, and @file://file, except that - // @username will be treated as a literal string if no "username" file exists - expectsFile := true - if withoutPrefix, ok := strings.CutPrefix(filename, "data://"); ok { - filename = withoutPrefix - } else if withoutPrefix, ok := strings.CutPrefix(filename, "file://"); ok { - filename = withoutPrefix - } else { - expectsFile = strings.Contains(filename, ".") || strings.Contains(filename, "/") - } - - if isStdinPath(filename) { - r, err := stdin.read() - if err != nil { - return v, err - } - return reflect.ValueOf(io.NopCloser(r)), nil - } - - upload, err := openFileUpload(filename) - if err != nil { - if !expectsFile { - // For strings that start with "@" and don't look like a filename, return the string - return v, nil - } - return v, err - } - return reflect.ValueOf(upload), nil - } - } - return v, nil - - default: - return v, nil - } -} - -// Guess whether a file's contents are binary (e.g. a .jpg or .mp3), as opposed -// to plain text (e.g. .txt or .md). -func isUTF8TextFile(content []byte) bool { - // Go's DetectContentType follows https://mimesniff.spec.whatwg.org/ and - // these are the sniffable content types that are plain text: - textTypes := []string{ - "text/", - "application/json", - "application/xml", - "application/javascript", - "application/x-javascript", - "application/ecmascript", - "application/x-ecmascript", - } - - contentType := http.DetectContentType(content) - for _, prefix := range textTypes { - if strings.HasPrefix(contentType, prefix) { - return utf8.Valid(content) - } - } - return false -} - -func flagOptions( - cmd *cli.Command, - nestedFormat apiquery.NestedQueryFormat, - arrayFormat apiquery.ArrayQueryFormat, - bodyType BodyContentType, - - // This parameter is true if stdin is already in use to pass a binary parameter by using the special value - // "-". In this case, we won't attempt to read it as a JSON/YAML blob for options setting. - ignoreStdin bool, -) ([]option.RequestOption, error) { - var options []option.RequestOption - if cmd.Bool("debug") { - options = append(options, option.WithMiddleware(debugmiddleware.NewRequestLogger().Middleware())) - } - - requestContents := requestflag.ExtractRequestContents(cmd) - - // Translate inner-field aliases in YAML values that came from flags (e.g. - // `--parent '{"alias": val}'` resolving to the canonical inner field). - if bodyMap, ok := requestContents.Body.(map[string]any); ok { - applyDataAliases(cmd, bodyMap) - } - - stdinConsumedByPipe := false - if bodyType != ApplicationOctetStream && !ignoreStdin && isInputPiped() { - pipeData, err := io.ReadAll(os.Stdin) - if err != nil { - return nil, err - } - - if len(pipeData) > 0 { - stdinConsumedByPipe = true - var bodyData any - if err := yaml.Unmarshal(pipeData, &bodyData); err != nil { - return nil, fmt.Errorf("Failed to parse piped data as YAML/JSON:\n%w", err) - } - if bodyMap, ok := bodyData.(map[string]any); ok { - applyDataAliases(cmd, bodyMap) - // Apply any matching keys from the piped data to path, query, and header flags - // that have not already been set via the command line. - if err := requestflag.ApplyStdinDataToFlags(cmd, bodyMap); err != nil { - return nil, err - } - // Re-extract request contents now that flags may have been updated. - requestContents = requestflag.ExtractRequestContents(cmd) - // Remove keys that were consumed as query, header, or path params so they - // don't also leak into the request body via the maps.Copy merge below. - // We delete both the canonical key and any aliases since the user may have - // piped data using an alias name rather than the canonical API name. - for _, flag := range cmd.Flags { - inReq, ok := flag.(requestflag.InRequest) - if !ok || !flag.IsSet() { - continue - } - if inReq.GetQueryPath() != "" || inReq.GetHeaderPath() != "" || inReq.GetPathParam() != "" { - delete(bodyMap, inReq.GetQueryPath()) - delete(bodyMap, inReq.GetHeaderPath()) - delete(bodyMap, inReq.GetPathParam()) - for _, alias := range inReq.GetDataAliases() { - delete(bodyMap, alias) - } - } - } - if bodyType != EmptyBody { - if flagMap, ok := requestContents.Body.(map[string]any); ok { - maps.Copy(bodyMap, flagMap) - requestContents.Body = bodyMap - } else { - bodyData = requestContents.Body - } - } - } else if bodyType != EmptyBody { - if flagMap, ok := requestContents.Body.(map[string]any); ok && len(flagMap) > 0 { - return nil, fmt.Errorf("Cannot merge flags with a body that is not a map: %v", bodyData) - } else { - requestContents.Body = bodyData - } - } - } - } - - if missingFlags := requestflag.GetMissingRequiredFlags(cmd, requestContents.Body); len(missingFlags) > 0 { - if len(missingFlags) == 1 { - return nil, fmt.Errorf("Required flag %q not set\nRun '%s --help' for usage information", missingFlags[0].Names()[0], cmd.FullName()) - } else { - names := []string{} - for _, flag := range missingFlags { - names = append(names, flag.Names()[0]) - } - return nil, fmt.Errorf("Required flags %q not set\nRun '%s --help' for usage information", strings.Join(names, ", "), cmd.FullName()) - } - } - - // For flags marked as FileInput (type: string, format: binary), the value is always - // a file path. Wrap with FilePathValue so embedFiles reads the file automatically - // without requiring the user to type the "@" prefix. This handles both values set - // via explicit CLI flags and values that arrived via piped YAML/JSON data. - wrapFileInputValues(cmd, &requestContents) - - // Determine stdin availability for FileInput params that use "-". - var stdinReader onceStdinReader - if ignoreStdin { - stdinReader = onceStdinReader{failureReason: "stdin is already being used for the request body"} - } else if stdinConsumedByPipe { - stdinReader = onceStdinReader{failureReason: "stdin was already consumed by piped YAML/JSON input"} - } else { - stdinReader = onceStdinReader{stdinReader: os.Stdin} - } - - // Embed files passed as "@file.jpg" in the request body, headers, and query: - embedStyle := EmbedText - if bodyType == ApplicationOctetStream || bodyType == MultipartFormEncoded { - embedStyle = EmbedIOReader - } - - if embedded, err := embedFiles(requestContents.Body, embedStyle, &stdinReader); err != nil { - return nil, err - } else { - requestContents.Body = embedded - } - - if headersWithFiles, err := embedFiles(requestContents.Headers, EmbedText, &stdinReader); err != nil { - return nil, err - } else { - requestContents.Headers = headersWithFiles.(map[string]any) - } - if queriesWithFiles, err := embedFiles(requestContents.Queries, EmbedText, &stdinReader); err != nil { - return nil, err - } else { - requestContents.Queries = queriesWithFiles.(map[string]any) - } - - querySettings := apiquery.QuerySettings{ - NestedFormat: nestedFormat, - ArrayFormat: arrayFormat, - } - - // Add query parameters: - if values, err := apiquery.MarshalWithSettings(requestContents.Queries, querySettings); err != nil { - return nil, err - } else { - for k, vs := range values { - if len(vs) == 0 { - options = append(options, option.WithQueryDel(k)) - } else { - options = append(options, option.WithQuery(k, vs[0])) - for _, v := range vs[1:] { - options = append(options, option.WithQueryAdd(k, v)) - } - } - } - } - - // Add header parameters - headerSettings := apiquery.QuerySettings{ - NestedFormat: apiquery.NestedQueryFormatDots, - ArrayFormat: apiquery.ArrayQueryFormatRepeat, - } - if values, err := apiquery.MarshalWithSettings(requestContents.Headers, headerSettings); err != nil { - return nil, err - } else { - for k, vs := range values { - if len(vs) == 0 { - options = append(options, option.WithHeaderDel(k)) - } else { - options = append(options, option.WithHeader(k, vs[0])) - for _, v := range vs[1:] { - options = append(options, option.WithHeaderAdd(k, v)) - } - } - } - } - - switch bodyType { - case EmptyBody: - break - case MultipartFormEncoded: - buf := new(bytes.Buffer) - writer := multipart.NewWriter(buf) - - // For multipart/form-encoded, we need a map structure - bodyMap, ok := requestContents.Body.(map[string]any) - if !ok { - return nil, fmt.Errorf("Cannot send a non-map value to a form-encoded endpoint: %v\n", requestContents.Body) - } - encodingFormat := apiform.FormatComma - if err := apiform.MarshalWithSettings(bodyMap, writer, encodingFormat); err != nil { - return nil, err - } - if err := writer.Close(); err != nil { - return nil, err - } - options = append(options, option.WithRequestBody(writer.FormDataContentType(), buf)) - - case ApplicationJSON: - bodyBytes, err := json.Marshal(requestContents.Body) - if err != nil { - return nil, err - } - options = append(options, option.WithRequestBody("application/json", bodyBytes)) - - case ApplicationOctetStream: - // If there is a body root parameter, that will handle setting the request body, we don't need to do it here. - for _, flag := range cmd.Flags { - if toSend, ok := flag.(requestflag.InRequest); ok && toSend.IsBodyRoot() { - return options, nil - } - } - if bodyBytes, ok := requestContents.Body.([]byte); ok { - options = append(options, option.WithRequestBody("application/octet-stream", bodyBytes)) - } else if bodyStr, ok := requestContents.Body.(string); ok { - options = append(options, option.WithRequestBody("application/octet-stream", []byte(bodyStr))) - } else { - return nil, fmt.Errorf("Unsupported body for application/octet-stream: %v", requestContents.Body) - } - - default: - panic("Invalid body content type!") - } - - return options, nil -} - -// FilePathValue is a string wrapper that marks a value as a file path whose contents should be read -// and embedded in the request. Unlike a regular string, embedFilesValue always treats a FilePathValue -// as a file path without needing the "@" prefix. -type FilePathValue string - -// fileUpload wraps an io.Reader with filename and content-type metadata for -// use as a multipart form part. The apiform encoder detects the Filename and -// ContentType methods and uses them to populate the Content-Disposition -// filename and the Content-Type header on the part. -type fileUpload struct { - io.Reader // apiform checks for reader and reads its contents during encode - filename string - contentType string -} - -func (f fileUpload) Filename() string { return f.filename } -func (f fileUpload) ContentType() string { return f.contentType } -func (f fileUpload) Close() error { - if c, ok := f.Reader.(io.Closer); ok { - return c.Close() - } - return nil -} - -// openFileUpload opens the file at path and returns a fileUpload whose filename -// is the path's basename and whose content type is derived from the file -// extension (falling back to application/octet-stream when unknown). -func openFileUpload(path string) (fileUpload, error) { - file, err := os.Open(path) - if err != nil { - return fileUpload{}, err - } - contentType := mime.TypeByExtension(filepath.Ext(path)) - if contentType == "" { - contentType = "application/octet-stream" - } - return fileUpload{ - Reader: file, - filename: filepath.Base(path), - contentType: contentType, - }, nil -} - -// applyDataAliases rewrites keys in a body map based on flag `DataAliases` metadata. For top-level flags, -// `{alias: value}` becomes `{canonical: value}`. For inner flags (those registered under an outer flag -// via WithInnerFlags), the alias translation is also applied to the nested map under the outer flag's -// body path, so values like `--parent '{"alias": val}'` resolve to the canonical inner field name. -func applyDataAliases(cmd *cli.Command, bodyMap map[string]any) { - for _, flag := range cmd.Flags { - // Inner flags: rewrite aliases inside the nested map under the outer flag's body path. - if inner, ok := flag.(requestflag.HasOuterFlag); ok { - outer, outerOk := inner.GetOuterFlag().(requestflag.InRequest) - if !outerOk { - continue - } - if nested, ok := bodyMap[outer.GetBodyPath()].(map[string]any); ok && inner.GetInnerField() != "" { - rewriteAliases(nested, inner.GetInnerField(), inner.GetDataAliases()) - } - continue - } - // Top-level flags: rewrite aliases in the body map. - if inReq, ok := flag.(requestflag.InRequest); ok && inReq.GetBodyPath() != "" { - rewriteAliases(bodyMap, inReq.GetBodyPath(), inReq.GetDataAliases()) - } - } -} - -// rewriteAliases replaces each alias key in m with the canonical key, preserving the value. The -// "canonical" key is the name the API itself expects (the OpenAPI property/field name) — e.g. for -// a top-level flag, the parameter's BodyPath; for an inner flag, the inner field name. Aliases are -// the user-facing alternate names declared via x-stainless-cli-data-alias. -func rewriteAliases(m map[string]any, canonical string, aliases []string) { - for _, alias := range aliases { - if alias == "" || alias == canonical { - continue - } - if val, exists := m[alias]; exists { - m[canonical] = val - delete(m, alias) - } - } -} - -// wrapFileInputValues replaces string values for FileInput flags (type: string, format: binary) with -// FilePathValue sentinel values. embedFilesValue recognizes FilePathValue and reads the file contents -// directly, so the user doesn't need to type the "@" prefix. This handles both values set via explicit -// CLI flags and values that arrived via piped YAML/JSON data. -func wrapFileInputValues(cmd *cli.Command, contents *requestflag.RequestContents) { - bodyMap, _ := contents.Body.(map[string]any) - - for _, flag := range cmd.Flags { - inReq, ok := flag.(requestflag.InRequest) - if !ok || !inReq.IsFileInput() || inReq.IsBodyRoot() { - continue - } - - // Wrap values set via explicit CLI flags. - if flag.IsSet() { - if wrapped, changed := wrapFileInputValue(flag.Get()); changed { - if bodyPath := inReq.GetBodyPath(); bodyPath != "" { - if bodyMap != nil { - bodyMap[bodyPath] = wrapped - } - } else if queryPath := inReq.GetQueryPath(); queryPath != "" { - contents.Queries[queryPath] = wrapped - } else if headerPath := inReq.GetHeaderPath(); headerPath != "" { - contents.Headers[headerPath] = wrapped - } - } - } - - // Wrap values that arrived via piped YAML/JSON data in the body map. - if bodyPath := inReq.GetBodyPath(); bodyPath != "" && bodyMap != nil { - if value, exists := bodyMap[bodyPath]; exists { - if wrapped, changed := wrapFileInputValue(value); changed { - bodyMap[bodyPath] = wrapped - } - } - } - } -} - -func wrapFileInputValue(value any) (any, bool) { - switch v := value.(type) { - case string: - if v == "" { - return value, false - } - return FilePathValue(v), true - - case []string: - result := make([]any, len(v)) - for i, s := range v { - result[i] = FilePathValue(s) - } - return result, true - - case []any: - result := make([]any, len(v)) - for i, elem := range v { - if s, ok := elem.(string); ok { - result[i] = FilePathValue(s) - } else { - result[i] = elem - } - } - return result, true - - default: - return value, false - } -} diff --git a/pkg/cmd/flagoptions_test.go b/pkg/cmd/flagoptions_test.go deleted file mode 100644 index 00734ca..0000000 --- a/pkg/cmd/flagoptions_test.go +++ /dev/null @@ -1,392 +0,0 @@ -package cmd - -import ( - "encoding/base64" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestIsUTF8TextFile(t *testing.T) { - t.Parallel() - - tests := []struct { - content []byte - expected bool - }{ - {[]byte("Hello, world!"), true}, - {[]byte(`{"key": "value"}`), true}, - {[]byte(``), true}, - {[]byte(`function test() {}`), true}, - {[]byte{0xFF, 0xD8, 0xFF, 0xE0}, false}, // JPEG header - {[]byte{0x00, 0x01, 0xFF, 0xFE}, false}, // binary - {[]byte("Hello \xFF\xFE"), false}, // invalid UTF-8 - {[]byte("Hello ☺️"), true}, // emoji - {[]byte{}, true}, // empty - } - - for _, tt := range tests { - require.Equal(t, tt.expected, isUTF8TextFile(tt.content)) - } -} - -func TestEmbedFiles(t *testing.T) { - t.Parallel() - - // Create temporary directory for test files - tmpDir := t.TempDir() - - // Create test files - configContent := "host=localhost\nport=8080" - templateContent := "Hello" - dataContent := `{"key": "value"}` - - writeTestFile(t, tmpDir, "config.txt", configContent) - writeTestFile(t, tmpDir, "template.html", templateContent) - writeTestFile(t, tmpDir, "data.json", dataContent) - jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46} - writeTestFile(t, tmpDir, "image.jpg", string(jpegHeader)) - - tests := []struct { - name string - input any - want any - wantErr bool - }{ - { - name: "map[string]any with file references", - input: map[string]any{ - "config": "@" + filepath.Join(tmpDir, "config.txt"), - "template": "@file://" + filepath.Join(tmpDir, "template.html"), - "count": 42, - }, - want: map[string]any{ - "config": configContent, - "template": templateContent, - "count": 42, - }, - wantErr: false, - }, - { - name: "map[string]string with file references", - input: map[string]any{ - "config": "@" + filepath.Join(tmpDir, "config.txt"), - "name": "test", - }, - want: map[string]any{ - "config": configContent, - "name": "test", - }, - wantErr: false, - }, - { - name: "[]any with file references", - input: []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - 42, - true, - "@file://" + filepath.Join(tmpDir, "data.json"), - }, - want: []any{ - configContent, - 42, - true, - dataContent, - }, - wantErr: false, - }, - { - name: "[]string with file references", - input: []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - "normal string", - }, - want: []any{ - configContent, - "normal string", - }, - wantErr: false, - }, - { - name: "nested structures", - input: map[string]any{ - "outer": map[string]any{ - "inner": []any{ - "@" + filepath.Join(tmpDir, "config.txt"), - map[string]any{ - "data": "@" + filepath.Join(tmpDir, "data.json"), - }, - }, - }, - }, - want: map[string]any{ - "outer": map[string]any{ - "inner": []any{ - configContent, - map[string]any{ - "data": dataContent, - }, - }, - }, - }, - wantErr: false, - }, - { - name: "base64 encoding", - input: map[string]any{ - "encoded": "@data://" + filepath.Join(tmpDir, "config.txt"), - "image": "@" + filepath.Join(tmpDir, "image.jpg"), - }, - want: map[string]any{ - "encoded": base64.StdEncoding.EncodeToString([]byte(configContent)), - "image": base64.StdEncoding.EncodeToString(jpegHeader), - }, - wantErr: false, - }, - { - name: "non-existent file with @ prefix", - input: map[string]any{ - "missing": "@file.txt", - }, - want: nil, - wantErr: true, - }, - { - name: "non-file-like thing with @ prefix", - input: map[string]any{ - "username": "@user", - "favorite_symbol": "@", - }, - want: map[string]any{ - "username": "@user", - "favorite_symbol": "@", - }, - wantErr: false, - }, - { - name: "non-existent file with @file:// prefix (error)", - input: map[string]any{ - "missing": "@file:///nonexistent/file.txt", - }, - want: nil, - wantErr: true, - }, - { - name: "escaping", - input: map[string]any{ - "simple": "\\@file.txt", - "file": "\\@file://file.txt", - "data": "\\@data://file.txt", - "keep_escape": "user\\@example.com", - }, - want: map[string]any{ - "simple": "@file.txt", - "file": "@file://file.txt", - "data": "@data://file.txt", - "keep_escape": "user\\@example.com", - }, - wantErr: false, - }, - { - name: "primitive types", - input: map[string]any{ - "int": 123, - "float": 45.67, - "bool": true, - "null": nil, - "string": "no prefix", - "email": "user@example.com", - }, - want: map[string]any{ - "int": 123, - "float": 45.67, - "bool": true, - "null": nil, - "string": "no prefix", - "email": "user@example.com", - }, - wantErr: false, - }, - { - name: "[]int values unchanged", - input: []int{1, 2, 3, 4, 5}, - want: []any{1, 2, 3, 4, 5}, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name+" text", func(t *testing.T) { - t.Parallel() - - got, err := embedFiles(tt.input, EmbedText, nil) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - require.Equal(t, tt.want, got) - } - }) - - t.Run(tt.name+" io.Reader", func(t *testing.T) { - t.Parallel() - - _, err := embedFiles(tt.input, EmbedIOReader, nil) - if tt.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - }) - } -} - -func TestEmbedFilesStdin(t *testing.T) { - t.Parallel() - - t.Run("FilePathValueDash", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} - - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) - }) - - t.Run("FilePathValueDevStdin", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} - - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue("/dev/stdin")}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"file": "stdin content"}, withEmbedded) - }) - - t.Run("MultipleFilePathValueDashesError", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("stdin content")} - - _, err := embedFiles(map[string]any{ - "file1": FilePathValue("-"), - "file2": FilePathValue("-"), - }, EmbedText, stdin) - require.Error(t, err) - require.Contains(t, err.Error(), "already been read") - }) - - t.Run("FilePathValueDashUnavailableStdin", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{failureReason: "stdin is already being used for the request body"} - - _, err := embedFiles(map[string]any{"file": FilePathValue("-")}, EmbedText, stdin) - require.Error(t, err) - require.Contains(t, err.Error(), "cannot read from stdin") - require.Contains(t, err.Error(), "request body") - }) - - t.Run("AtDashEmbedText", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} - - withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"data": "piped content"}, withEmbedded) - }) - - t.Run("AtDashEmbedIOReader", func(t *testing.T) { - t.Parallel() - - stdin := &onceStdinReader{stdinReader: strings.NewReader("piped content")} - - withEmbedded, err := embedFiles(map[string]any{"data": "@-"}, EmbedIOReader, stdin) - require.NoError(t, err) - - withEmbeddedMap := withEmbedded.(map[string]any) - r := withEmbeddedMap["data"].(io.ReadCloser) - - content, err := io.ReadAll(r) - require.NoError(t, err) - require.Equal(t, "piped content", string(content)) - }) - - t.Run("FilePathValueRealFile", func(t *testing.T) { - t.Parallel() - - tmpDir := t.TempDir() - writeTestFile(t, tmpDir, "test.txt", "file content") - - stdin := &onceStdinReader{stdinReader: strings.NewReader("unused stdin")} - - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(filepath.Join(tmpDir, "test.txt"))}, EmbedText, stdin) - require.NoError(t, err) - require.Equal(t, map[string]any{"file": "file content"}, withEmbedded) - }) -} - -// TestEmbedFilesUploadMetadata verifies that EmbedIOReader mode wraps file readers with filename and -// content-type metadata so the multipart encoder populates `Content-Disposition` and `Content-Type` headers. -func TestEmbedFilesUploadMetadata(t *testing.T) { - t.Parallel() - - tmpDir := t.TempDir() - writeTestFile(t, tmpDir, "hello.txt", "hi") - writeTestFile(t, tmpDir, "page.html", "") - writeTestFile(t, tmpDir, "blob.bin", "\x00\x01") - - cases := []struct { - basename string - wantContentType string - }{ - {"hello.txt", "text/plain; charset=utf-8"}, - {"page.html", "text/html; charset=utf-8"}, - {"blob.bin", "application/octet-stream"}, - } - - for _, tc := range cases { - t.Run("AtPrefix_"+tc.basename, func(t *testing.T) { - t.Parallel() - - path := filepath.Join(tmpDir, tc.basename) - withEmbedded, err := embedFiles(map[string]any{"file": "@" + path}, EmbedIOReader, nil) - require.NoError(t, err) - - upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) - require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) - require.Equal(t, tc.basename, upload.Filename()) - require.Equal(t, upload.ContentType(), tc.wantContentType) - require.NoError(t, upload.Close()) - }) - - t.Run("FilePathValue_"+tc.basename, func(t *testing.T) { - t.Parallel() - - path := filepath.Join(tmpDir, tc.basename) - withEmbedded, err := embedFiles(map[string]any{"file": FilePathValue(path)}, EmbedIOReader, nil) - require.NoError(t, err) - - upload, ok := withEmbedded.(map[string]any)["file"].(fileUpload) - require.True(t, ok, "expected fileUpload, got %T", withEmbedded.(map[string]any)["file"]) - require.Equal(t, tc.basename, upload.Filename()) - require.Equal(t, upload.ContentType(), tc.wantContentType) - require.NoError(t, upload.Close()) - }) - } -} - -func writeTestFile(t *testing.T, dir, filename, content string) { - t.Helper() - - path := filepath.Join(dir, filename) - - err := os.WriteFile(path, []byte(content), 0644) - require.NoError(t, err, "failed to write test file %s", path) -} diff --git a/pkg/cmd/inbox.go b/pkg/cmd/inbox.go deleted file mode 100644 index ae91601..0000000 --- a/pkg/cmd/inbox.go +++ /dev/null @@ -1,331 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var inboxesCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*string]{ - Name: "client-id", - Usage: "Client ID of inbox.", - BodyPath: "client_id", - }, - &requestflag.Flag[*string]{ - Name: "display-name", - Usage: "Display name: `Display Name `.", - BodyPath: "display_name", - }, - &requestflag.Flag[*string]{ - Name: "domain", - Usage: "Domain of address. Must be a verified domain, or any subdomain of a\nverified domain that has subdomains enabled (e.g., `bot.example.com`).\nDefaults to `agentmail.to`.", - BodyPath: "domain", - }, - &requestflag.Flag[map[string]any]{ - Name: "metadata", - Usage: "Custom metadata to attach to the inbox.", - BodyPath: "metadata", - }, - &requestflag.Flag[*string]{ - Name: "username", - Usage: "Username of address. Randomly generated if not specified.", - BodyPath: "username", - }, - }, - Action: handleInboxesCreate, - HideHelpCommand: true, -} - -var inboxesUpdate = cli.Command{ - Name: "update", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[*string]{ - Name: "display-name", - Usage: "Display name: `Display Name `.", - BodyPath: "display_name", - }, - &requestflag.Flag[map[string]any]{ - Name: "metadata", - Usage: "Metadata to merge into the inbox's existing metadata. Keys you include\nare added or overwritten; keys you omit are left unchanged. To remove a\nsingle key, send it with a null value. To clear all metadata, send\n`metadata` as null. Sending an empty object is rejected; use null to\nclear. Each update must include at least one of `display_name` or\n`metadata`.", - BodyPath: "metadata", - }, - }, - Action: handleInboxesUpdate, - HideHelpCommand: true, -} - -var inboxesList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleInboxesList, - HideHelpCommand: true, -} - -var inboxesDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - }, - Action: handleInboxesDelete, - HideHelpCommand: true, -} - -var inboxesGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - }, - Action: handleInboxesGet, - HideHelpCommand: true, -} - -func handleInboxesCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes create", - Transform: transform, - }) -} - -func handleInboxesUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxUpdateParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Update( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes update", - Transform: transform, - }) -} - -func handleInboxesList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes list", - Transform: transform, - }) -} - -func handleInboxesDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Inboxes.Delete(ctx, cmd.Value("inbox-id").(string), options...) -} - -func handleInboxesGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Get(ctx, cmd.Value("inbox-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes get", - Transform: transform, - }) -} diff --git a/pkg/cmd/inbox_test.go b/pkg/cmd/inbox_test.go deleted file mode 100644 index 212e650..0000000 --- a/pkg/cmd/inbox_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestInboxesCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes", "create", - "--client-id", "client_id", - "--display-name", "display_name", - "--domain", "domain", - "--metadata", "{foo: string}", - "--username", "username", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "client_id: client_id\n" + - "display_name: display_name\n" + - "domain: domain\n" + - "metadata:\n" + - " foo: string\n" + - "username: username\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes", "create", - ) - }) -} - -func TestInboxesUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes", "update", - "--inbox-id", "inbox_id", - "--display-name", "display_name", - "--metadata", "{foo: string}", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "display_name: display_name\n" + - "metadata:\n" + - " foo: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes", "update", - "--inbox-id", "inbox_id", - ) - }) -} - -func TestInboxesList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes", "list", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestInboxesDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes", "delete", - "--inbox-id", "inbox_id", - ) - }) -} - -func TestInboxesGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes", "get", - "--inbox-id", "inbox_id", - ) - }) -} diff --git a/pkg/cmd/inboxapikey.go b/pkg/cmd/inboxapikey.go deleted file mode 100644 index 4478dd7..0000000 --- a/pkg/cmd/inboxapikey.go +++ /dev/null @@ -1,399 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var inboxesAPIKeysCreate = requestflag.WithInnerFlags(cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[*string]{ - Name: "name", - Usage: "Name of api key.", - BodyPath: "name", - }, - &requestflag.Flag[map[string]any]{ - Name: "permissions", - Usage: "Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted.", - BodyPath: "permissions", - }, - }, - Action: handleInboxesAPIKeysCreate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "permissions": { - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-create", - Usage: "Create API keys.", - InnerField: "api_key_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-delete", - Usage: "Delete API keys.", - InnerField: "api_key_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-read", - Usage: "Read API keys.", - InnerField: "api_key_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-create", - Usage: "Create domains.", - InnerField: "domain_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-delete", - Usage: "Delete domains.", - InnerField: "domain_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-read", - Usage: "Read domain details.", - InnerField: "domain_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-update", - Usage: "Update domains.", - InnerField: "domain_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-create", - Usage: "Create drafts.", - InnerField: "draft_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-delete", - Usage: "Delete drafts.", - InnerField: "draft_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-read", - Usage: "Read drafts.", - InnerField: "draft_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-send", - Usage: "Send drafts.", - InnerField: "draft_send", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-update", - Usage: "Update drafts.", - InnerField: "draft_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-create", - Usage: "Create new inboxes.", - InnerField: "inbox_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-delete", - Usage: "Delete inboxes.", - InnerField: "inbox_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-read", - Usage: "Read inbox details.", - InnerField: "inbox_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-update", - Usage: "Update inbox settings.", - InnerField: "inbox_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-blocked-read", - Usage: "Access messages labeled blocked.", - InnerField: "label_blocked_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-spam-read", - Usage: "Access messages labeled spam.", - InnerField: "label_spam_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-trash-read", - Usage: "Access messages labeled trash.", - InnerField: "label_trash_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-create", - Usage: "Create list entries.", - InnerField: "list_entry_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-delete", - Usage: "Delete list entries.", - InnerField: "list_entry_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-read", - Usage: "Read list entries.", - InnerField: "list_entry_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-read", - Usage: "Read messages.", - InnerField: "message_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-send", - Usage: "Send messages.", - InnerField: "message_send", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-update", - Usage: "Update message labels.", - InnerField: "message_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.metrics-read", - Usage: "Read metrics.", - InnerField: "metrics_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-create", - Usage: "Create pods.", - InnerField: "pod_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-delete", - Usage: "Delete pods.", - InnerField: "pod_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-read", - Usage: "Read pods.", - InnerField: "pod_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.thread-delete", - Usage: "Delete threads.", - InnerField: "thread_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.thread-read", - Usage: "Read threads.", - InnerField: "thread_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-create", - Usage: "Create webhooks.", - InnerField: "webhook_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-delete", - Usage: "Delete webhooks.", - InnerField: "webhook_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-read", - Usage: "Read webhook configurations.", - InnerField: "webhook_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-update", - Usage: "Update webhooks.", - InnerField: "webhook_update", - }, - }, -}) - -var inboxesAPIKeysList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleInboxesAPIKeysList, - HideHelpCommand: true, -} - -var inboxesAPIKeysDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "api-key-id", - Usage: "ID of api key.", - Required: true, - PathParam: "api_key_id", - }, - }, - Action: handleInboxesAPIKeysDelete, - HideHelpCommand: true, -} - -func handleInboxesAPIKeysCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxAPIKeyNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.APIKeys.New( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:api-keys create", - Transform: transform, - }) -} - -func handleInboxesAPIKeysList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxAPIKeyListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.APIKeys.List( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:api-keys list", - Transform: transform, - }) -} - -func handleInboxesAPIKeysDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("api-key-id") && len(unusedArgs) > 0 { - cmd.Set("api-key-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxAPIKeyDeleteParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - return client.Inboxes.APIKeys.Delete( - ctx, - cmd.Value("api-key-id").(string), - params, - options..., - ) -} diff --git a/pkg/cmd/inboxapikey_test.go b/pkg/cmd/inboxapikey_test.go deleted file mode 100644 index 363128d..0000000 --- a/pkg/cmd/inboxapikey_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" -) - -func TestInboxesAPIKeysCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:api-keys", "create", - "--inbox-id", "inbox_id", - "--name", "name", - "--permissions", "{api_key_create: true, api_key_delete: true, api_key_read: true, domain_create: true, domain_delete: true, domain_read: true, domain_update: true, draft_create: true, draft_delete: true, draft_read: true, draft_send: true, draft_update: true, inbox_create: true, inbox_delete: true, inbox_read: true, inbox_update: true, label_blocked_read: true, label_spam_read: true, label_trash_read: true, list_entry_create: true, list_entry_delete: true, list_entry_read: true, message_read: true, message_send: true, message_update: true, metrics_read: true, pod_create: true, pod_delete: true, pod_read: true, thread_delete: true, thread_read: true, webhook_create: true, webhook_delete: true, webhook_read: true, webhook_update: true}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesAPIKeysCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:api-keys", "create", - "--inbox-id", "inbox_id", - "--name", "name", - "--permissions.api-key-create=true", - "--permissions.api-key-delete=true", - "--permissions.api-key-read=true", - "--permissions.domain-create=true", - "--permissions.domain-delete=true", - "--permissions.domain-read=true", - "--permissions.domain-update=true", - "--permissions.draft-create=true", - "--permissions.draft-delete=true", - "--permissions.draft-read=true", - "--permissions.draft-send=true", - "--permissions.draft-update=true", - "--permissions.inbox-create=true", - "--permissions.inbox-delete=true", - "--permissions.inbox-read=true", - "--permissions.inbox-update=true", - "--permissions.label-blocked-read=true", - "--permissions.label-spam-read=true", - "--permissions.label-trash-read=true", - "--permissions.list-entry-create=true", - "--permissions.list-entry-delete=true", - "--permissions.list-entry-read=true", - "--permissions.message-read=true", - "--permissions.message-send=true", - "--permissions.message-update=true", - "--permissions.metrics-read=true", - "--permissions.pod-create=true", - "--permissions.pod-delete=true", - "--permissions.pod-read=true", - "--permissions.thread-delete=true", - "--permissions.thread-read=true", - "--permissions.webhook-create=true", - "--permissions.webhook-delete=true", - "--permissions.webhook-read=true", - "--permissions.webhook-update=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "name: name\n" + - "permissions:\n" + - " api_key_create: true\n" + - " api_key_delete: true\n" + - " api_key_read: true\n" + - " domain_create: true\n" + - " domain_delete: true\n" + - " domain_read: true\n" + - " domain_update: true\n" + - " draft_create: true\n" + - " draft_delete: true\n" + - " draft_read: true\n" + - " draft_send: true\n" + - " draft_update: true\n" + - " inbox_create: true\n" + - " inbox_delete: true\n" + - " inbox_read: true\n" + - " inbox_update: true\n" + - " label_blocked_read: true\n" + - " label_spam_read: true\n" + - " label_trash_read: true\n" + - " list_entry_create: true\n" + - " list_entry_delete: true\n" + - " list_entry_read: true\n" + - " message_read: true\n" + - " message_send: true\n" + - " message_update: true\n" + - " metrics_read: true\n" + - " pod_create: true\n" + - " pod_delete: true\n" + - " pod_read: true\n" + - " thread_delete: true\n" + - " thread_read: true\n" + - " webhook_create: true\n" + - " webhook_delete: true\n" + - " webhook_read: true\n" + - " webhook_update: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:api-keys", "create", - "--inbox-id", "inbox_id", - ) - }) -} - -func TestInboxesAPIKeysList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:api-keys", "list", - "--inbox-id", "inbox_id", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestInboxesAPIKeysDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:api-keys", "delete", - "--inbox-id", "inbox_id", - "--api-key-id", "api_key_id", - ) - }) -} diff --git a/pkg/cmd/inboxdraft.go b/pkg/cmd/inboxdraft.go deleted file mode 100644 index dfe127a..0000000 --- a/pkg/cmd/inboxdraft.go +++ /dev/null @@ -1,748 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var inboxesDraftsCreate = requestflag.WithInnerFlags(cli.Command{ - Name: "create", - Usage: "Create a draft. Supply `in_reply_to` to create a reply draft (with `reply_all`\nto address the whole thread), whose recipients, subject, and threading are\nderived from the referenced message, or `forward_of` to create a forward draft,\nwhich derives the subject, threading, and forwarded content from the source but\nkeeps recipients caller-supplied.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[any]{ - Name: "attachment", - Usage: "Attachments to include in draft.", - BodyPath: "attachments", - }, - &requestflag.Flag[any]{ - Name: "bcc", - Usage: "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `.", - BodyPath: "bcc", - }, - &requestflag.Flag[any]{ - Name: "cc", - Usage: "Addresses of CC recipients. In format `username@domain.com` or `Display Name `.", - BodyPath: "cc", - }, - &requestflag.Flag[*string]{ - Name: "client-id", - Usage: "Client ID of draft.", - BodyPath: "client_id", - }, - &requestflag.Flag[*string]{ - Name: "forward-of", - Usage: "ID of message being forwarded.", - BodyPath: "forward_of", - }, - &requestflag.Flag[*string]{ - Name: "html", - Usage: "HTML body of draft.", - BodyPath: "html", - }, - &requestflag.Flag[*string]{ - Name: "in-reply-to", - Usage: "ID of message being replied to.", - BodyPath: "in_reply_to", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels of draft.", - BodyPath: "labels", - }, - &requestflag.Flag[*bool]{ - Name: "reply-all", - Usage: "Reply to all recipients of the original message.", - BodyPath: "reply_all", - }, - &requestflag.Flag[any]{ - Name: "reply-to", - Usage: "Reply-to addresses. In format `username@domain.com` or `Display Name `.", - BodyPath: "reply_to", - }, - &requestflag.Flag[any]{ - Name: "send-at", - Usage: "Time at which to schedule send draft.", - BodyPath: "send_at", - }, - &requestflag.Flag[*string]{ - Name: "subject", - Usage: "Subject of draft.", - BodyPath: "subject", - }, - &requestflag.Flag[*string]{ - Name: "text", - Usage: "Plain text body of draft.", - BodyPath: "text", - }, - &requestflag.Flag[any]{ - Name: "to", - Usage: "Addresses of recipients. In format `username@domain.com` or `Display Name `.", - BodyPath: "to", - }, - }, - Action: handleInboxesDraftsCreate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "attachment": { - &requestflag.InnerFlag[*string]{ - Name: "attachment.content", - Usage: "Base64 encoded content of attachment.", - InnerField: "content", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-disposition", - Usage: "Content disposition of attachment.", - InnerField: "content_disposition", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-id", - Usage: "Content ID of attachment.", - InnerField: "content_id", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-type", - Usage: "Content type of attachment.", - InnerField: "content_type", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.filename", - Usage: "Filename of attachment.", - InnerField: "filename", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.url", - Usage: "URL to the attachment.", - InnerField: "url", - OuterIsArrayOfObjects: true, - }, - }, -}) - -var inboxesDraftsUpdate = requestflag.WithInnerFlags(cli.Command{ - Name: "update", - Usage: "Edit fields on an existing draft. Passing `null` clears a field (or `[]` for a\nrecipient field); `send_at: null` un-schedules a scheduled draft. A draft that\nis already being sent cannot be edited.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - &requestflag.Flag[any]{ - Name: "add-attachment", - Usage: "Attachments to add to the draft.", - BodyPath: "add_attachments", - }, - &requestflag.Flag[any]{ - Name: "add-label", - Usage: "Label or labels to add to the draft.", - BodyPath: "add_labels", - }, - &requestflag.Flag[any]{ - Name: "bcc", - Usage: "Addresses of BCC recipients. In format `username@domain.com` or `Display Name `.", - BodyPath: "bcc", - }, - &requestflag.Flag[any]{ - Name: "cc", - Usage: "Addresses of CC recipients. In format `username@domain.com` or `Display Name `.", - BodyPath: "cc", - }, - &requestflag.Flag[*string]{ - Name: "html", - Usage: "HTML body of draft.", - BodyPath: "html", - }, - &requestflag.Flag[any]{ - Name: "remove-attachment", - Usage: "IDs of attachments to remove from the draft.", - BodyPath: "remove_attachments", - }, - &requestflag.Flag[any]{ - Name: "remove-label", - Usage: "Label or labels to remove from the draft.", - BodyPath: "remove_labels", - }, - &requestflag.Flag[any]{ - Name: "reply-to", - Usage: "Reply-to addresses. In format `username@domain.com` or `Display Name `.", - BodyPath: "reply_to", - }, - &requestflag.Flag[any]{ - Name: "send-at", - Usage: "Time at which to schedule send draft.", - BodyPath: "send_at", - }, - &requestflag.Flag[*string]{ - Name: "subject", - Usage: "Subject of draft.", - BodyPath: "subject", - }, - &requestflag.Flag[*string]{ - Name: "text", - Usage: "Plain text body of draft.", - BodyPath: "text", - }, - &requestflag.Flag[any]{ - Name: "to", - Usage: "Addresses of recipients. In format `username@domain.com` or `Display Name `.", - BodyPath: "to", - }, - }, - Action: handleInboxesDraftsUpdate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "add-attachment": { - &requestflag.InnerFlag[*string]{ - Name: "add-attachment.content", - Usage: "Base64 encoded content of attachment.", - InnerField: "content", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "add-attachment.content-disposition", - Usage: "Content disposition of attachment.", - InnerField: "content_disposition", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "add-attachment.content-id", - Usage: "Content ID of attachment.", - InnerField: "content_id", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "add-attachment.content-type", - Usage: "Content type of attachment.", - InnerField: "content_type", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "add-attachment.filename", - Usage: "Filename of attachment.", - InnerField: "filename", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "add-attachment.url", - Usage: "URL to the attachment.", - InnerField: "url", - OuterIsArrayOfObjects: true, - }, - }, -}) - -var inboxesDraftsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleInboxesDraftsList, - HideHelpCommand: true, -} - -var inboxesDraftsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - }, - Action: handleInboxesDraftsDelete, - HideHelpCommand: true, -} - -var inboxesDraftsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - }, - Action: handleInboxesDraftsGet, - HideHelpCommand: true, -} - -var inboxesDraftsGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handleInboxesDraftsGetAttachment, - HideHelpCommand: true, -} - -var inboxesDraftsSend = cli.Command{ - Name: "send", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - &requestflag.Flag[any]{ - Name: "add-labels", - Usage: "Label or labels to add to message.", - BodyPath: "add_labels", - }, - &requestflag.Flag[any]{ - Name: "remove-labels", - Usage: "Label or labels to remove from message.", - BodyPath: "remove_labels", - }, - }, - Action: handleInboxesDraftsSend, - HideHelpCommand: true, -} - -func handleInboxesDraftsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Drafts.New( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:drafts create", - Transform: transform, - }) -} - -func handleInboxesDraftsUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { - cmd.Set("draft-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftUpdateParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Drafts.Update( - ctx, - cmd.Value("draft-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:drafts update", - Transform: transform, - }) -} - -func handleInboxesDraftsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Drafts.List( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:drafts list", - Transform: transform, - }) -} - -func handleInboxesDraftsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { - cmd.Set("draft-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftDeleteParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - return client.Inboxes.Drafts.Delete( - ctx, - cmd.Value("draft-id").(string), - params, - options..., - ) -} - -func handleInboxesDraftsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { - cmd.Set("draft-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftGetParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Drafts.Get( - ctx, - cmd.Value("draft-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:drafts get", - Transform: transform, - }) -} - -func handleInboxesDraftsGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftGetAttachmentParams{ - InboxID: cmd.Value("inbox-id").(string), - DraftID: cmd.Value("draft-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Drafts.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:drafts get-attachment", - Transform: transform, - }) -} - -func handleInboxesDraftsSend(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { - cmd.Set("draft-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxDraftSendParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Drafts.Send( - ctx, - cmd.Value("draft-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:drafts send", - Transform: transform, - }) -} diff --git a/pkg/cmd/inboxdraft_test.go b/pkg/cmd/inboxdraft_test.go deleted file mode 100644 index ef979b7..0000000 --- a/pkg/cmd/inboxdraft_test.go +++ /dev/null @@ -1,284 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" -) - -func TestInboxesDraftsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "create", - "--inbox-id", "inbox_id", - "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", - "--bcc", "[string]", - "--cc", "[string]", - "--client-id", "client_id", - "--forward-of", "forward_of", - "--html", "html", - "--in-reply-to", "in_reply_to", - "--label", "[string]", - "--reply-all=true", - "--reply-to", "[string]", - "--send-at", "'2019-12-27T18:11:19.117Z'", - "--subject", "subject", - "--text", "text", - "--to", "[string]", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesDraftsCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "create", - "--inbox-id", "inbox_id", - "--attachment.content", "content", - "--attachment.content-disposition", "inline", - "--attachment.content-id", "content_id", - "--attachment.content-type", "content_type", - "--attachment.filename", "filename", - "--attachment.url", "url", - "--bcc", "[string]", - "--cc", "[string]", - "--client-id", "client_id", - "--forward-of", "forward_of", - "--html", "html", - "--in-reply-to", "in_reply_to", - "--label", "[string]", - "--reply-all=true", - "--reply-to", "[string]", - "--send-at", "'2019-12-27T18:11:19.117Z'", - "--subject", "subject", - "--text", "text", - "--to", "[string]", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "attachments:\n" + - " - content: content\n" + - " content_disposition: inline\n" + - " content_id: content_id\n" + - " content_type: content_type\n" + - " filename: filename\n" + - " url: url\n" + - "bcc:\n" + - " - string\n" + - "cc:\n" + - " - string\n" + - "client_id: client_id\n" + - "forward_of: forward_of\n" + - "html: html\n" + - "in_reply_to: in_reply_to\n" + - "labels:\n" + - " - string\n" + - "reply_all: true\n" + - "reply_to:\n" + - " - string\n" + - "send_at: '2019-12-27T18:11:19.117Z'\n" + - "subject: subject\n" + - "text: text\n" + - "to:\n" + - " - string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:drafts", "create", - "--inbox-id", "inbox_id", - ) - }) -} - -func TestInboxesDraftsUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "update", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - "--add-attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", - "--add-label", "[string]", - "--bcc", "[string]", - "--cc", "[string]", - "--html", "html", - "--remove-attachment", "[string]", - "--remove-label", "[string]", - "--reply-to", "[string]", - "--send-at", "'2019-12-27T18:11:19.117Z'", - "--subject", "subject", - "--text", "text", - "--to", "[string]", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesDraftsUpdate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "update", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - "--add-attachment.content", "content", - "--add-attachment.content-disposition", "inline", - "--add-attachment.content-id", "content_id", - "--add-attachment.content-type", "content_type", - "--add-attachment.filename", "filename", - "--add-attachment.url", "url", - "--add-label", "[string]", - "--bcc", "[string]", - "--cc", "[string]", - "--html", "html", - "--remove-attachment", "[string]", - "--remove-label", "[string]", - "--reply-to", "[string]", - "--send-at", "'2019-12-27T18:11:19.117Z'", - "--subject", "subject", - "--text", "text", - "--to", "[string]", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "add_attachments:\n" + - " - content: content\n" + - " content_disposition: inline\n" + - " content_id: content_id\n" + - " content_type: content_type\n" + - " filename: filename\n" + - " url: url\n" + - "add_labels:\n" + - " - string\n" + - "bcc:\n" + - " - string\n" + - "cc:\n" + - " - string\n" + - "html: html\n" + - "remove_attachments:\n" + - " - string\n" + - "remove_labels:\n" + - " - string\n" + - "reply_to:\n" + - " - string\n" + - "send_at: '2019-12-27T18:11:19.117Z'\n" + - "subject: subject\n" + - "text: text\n" + - "to:\n" + - " - string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:drafts", "update", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - ) - }) -} - -func TestInboxesDraftsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "list", - "--inbox-id", "inbox_id", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestInboxesDraftsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "delete", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - ) - }) -} - -func TestInboxesDraftsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "get", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - ) - }) -} - -func TestInboxesDraftsGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "get-attachment", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - "--attachment-id", "attachment_id", - ) - }) -} - -func TestInboxesDraftsSend(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:drafts", "send", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - "--add-labels", "string", - "--remove-labels", "string", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "add_labels: string\n" + - "remove_labels: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:drafts", "send", - "--inbox-id", "inbox_id", - "--draft-id", "draft_id", - ) - }) -} diff --git a/pkg/cmd/inboxlist.go b/pkg/cmd/inboxlist.go deleted file mode 100644 index f926f49..0000000 --- a/pkg/cmd/inboxlist.go +++ /dev/null @@ -1,351 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var inboxesListsCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Usage: "Email address or domain to add.", - Required: true, - BodyPath: "entry", - }, - &requestflag.Flag[*string]{ - Name: "reason", - Usage: "Reason for adding the entry.", - BodyPath: "reason", - }, - }, - Action: handleInboxesListsCreate, - HideHelpCommand: true, -} - -var inboxesListsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleInboxesListsList, - HideHelpCommand: true, -} - -var inboxesListsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Required: true, - PathParam: "entry", - }, - }, - Action: handleInboxesListsDelete, - HideHelpCommand: true, -} - -var inboxesListsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Required: true, - PathParam: "entry", - }, - }, - Action: handleInboxesListsGet, - HideHelpCommand: true, -} - -func handleInboxesListsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("type") && len(unusedArgs) > 0 { - cmd.Set("type", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxListNewParams{ - InboxID: cmd.Value("inbox-id").(string), - Direction: agentmail.InboxListNewParamsDirection(cmd.Value("direction").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Lists.New( - ctx, - agentmail.InboxListNewParamsType(cmd.Value("type").(string)), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:lists create", - Transform: transform, - }) -} - -func handleInboxesListsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("type") && len(unusedArgs) > 0 { - cmd.Set("type", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxListListParams{ - InboxID: cmd.Value("inbox-id").(string), - Direction: agentmail.InboxListListParamsDirection(cmd.Value("direction").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Lists.List( - ctx, - agentmail.InboxListListParamsType(cmd.Value("type").(string)), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:lists list", - Transform: transform, - }) -} - -func handleInboxesListsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("entry") && len(unusedArgs) > 0 { - cmd.Set("entry", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxListDeleteParams{ - InboxID: cmd.Value("inbox-id").(string), - Direction: agentmail.InboxListDeleteParamsDirection(cmd.Value("direction").(string)), - Type: agentmail.InboxListDeleteParamsType(cmd.Value("type").(string)), - } - - return client.Inboxes.Lists.Delete( - ctx, - cmd.Value("entry").(string), - params, - options..., - ) -} - -func handleInboxesListsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("entry") && len(unusedArgs) > 0 { - cmd.Set("entry", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxListGetParams{ - InboxID: cmd.Value("inbox-id").(string), - Direction: agentmail.InboxListGetParamsDirection(cmd.Value("direction").(string)), - Type: agentmail.InboxListGetParamsType(cmd.Value("type").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Lists.Get( - ctx, - cmd.Value("entry").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:lists get", - Transform: transform, - }) -} diff --git a/pkg/cmd/inboxlist_test.go b/pkg/cmd/inboxlist_test.go deleted file mode 100644 index 6e06393..0000000 --- a/pkg/cmd/inboxlist_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestInboxesListsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:lists", "create", - "--inbox-id", "inbox_id", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - "--reason", "reason", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "entry: entry\n" + - "reason: reason\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:lists", "create", - "--inbox-id", "inbox_id", - "--direction", "send", - "--type", "allow", - ) - }) -} - -func TestInboxesListsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:lists", "list", - "--inbox-id", "inbox_id", - "--direction", "send", - "--type", "allow", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestInboxesListsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:lists", "delete", - "--inbox-id", "inbox_id", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - ) - }) -} - -func TestInboxesListsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:lists", "get", - "--inbox-id", "inbox_id", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - ) - }) -} diff --git a/pkg/cmd/inboxmessage.go b/pkg/cmd/inboxmessage.go deleted file mode 100644 index 5de6486..0000000 --- a/pkg/cmd/inboxmessage.go +++ /dev/null @@ -1,1152 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var inboxesMessagesUpdate = cli.Command{ - Name: "update", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - &requestflag.Flag[any]{ - Name: "add-labels", - Usage: "Label or labels to add to message.", - BodyPath: "add_labels", - }, - &requestflag.Flag[any]{ - Name: "remove-labels", - Usage: "Label or labels to remove from message.", - BodyPath: "remove_labels", - }, - }, - Action: handleInboxesMessagesUpdate, - HideHelpCommand: true, -} - -var inboxesMessagesList = cli.Command{ - Name: "list", - Usage: "Lists messages in the inbox, most recent first. Pass `from`, `to`, or `subject`\nto filter by substring. Filtered requests are served by search, which caps\n`limit` at 100. For relevance-ranked full-text search across sender, recipients,\nsubject, and message body, use `Search Messages`.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[any]{ - Name: "from", - Usage: "Filter to messages whose sender contains this value (substring match). Repeatable; all values must match.", - QueryPath: "from", - }, - &requestflag.Flag[*bool]{ - Name: "include-blocked", - Usage: "Include blocked in results.", - QueryPath: "include_blocked", - }, - &requestflag.Flag[*bool]{ - Name: "include-spam", - Usage: "Include spam in results.", - QueryPath: "include_spam", - }, - &requestflag.Flag[*bool]{ - Name: "include-trash", - Usage: "Include trash in results.", - QueryPath: "include_trash", - }, - &requestflag.Flag[*bool]{ - Name: "include-unauthenticated", - Usage: "Include unauthenticated in results.", - QueryPath: "include_unauthenticated", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - &requestflag.Flag[any]{ - Name: "subject", - Usage: "Filter to messages whose subject contains this value (substring match). Repeatable; all values must match.", - QueryPath: "subject", - }, - &requestflag.Flag[any]{ - Name: "to", - Usage: "Filter to messages whose recipients (to, cc, or bcc) contain this value (substring match). Repeatable; all values must match.", - QueryPath: "to", - }, - }, - Action: handleInboxesMessagesList, - HideHelpCommand: true, -} - -var inboxesMessagesForward = requestflag.WithInnerFlags(cli.Command{ - Name: "forward", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - &requestflag.Flag[any]{ - Name: "attachment", - Usage: "Attachments to include in message.", - BodyPath: "attachments", - }, - &requestflag.Flag[any]{ - Name: "bcc", - BodyPath: "bcc", - }, - &requestflag.Flag[any]{ - Name: "cc", - BodyPath: "cc", - }, - &requestflag.Flag[map[string]any]{ - Name: "headers", - Usage: "Headers to include in message.", - BodyPath: "headers", - }, - &requestflag.Flag[*string]{ - Name: "html", - Usage: "HTML body of message.", - BodyPath: "html", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels of message.", - BodyPath: "labels", - }, - &requestflag.Flag[any]{ - Name: "reply-to", - BodyPath: "reply_to", - }, - &requestflag.Flag[*string]{ - Name: "subject", - Usage: "Subject of message.", - BodyPath: "subject", - }, - &requestflag.Flag[*string]{ - Name: "text", - Usage: "Plain text body of message.", - BodyPath: "text", - }, - &requestflag.Flag[any]{ - Name: "to", - BodyPath: "to", - }, - }, - Action: handleInboxesMessagesForward, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "attachment": { - &requestflag.InnerFlag[*string]{ - Name: "attachment.content", - Usage: "Base64 encoded content of attachment.", - InnerField: "content", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-disposition", - Usage: "Content disposition of attachment.", - InnerField: "content_disposition", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-id", - Usage: "Content ID of attachment.", - InnerField: "content_id", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-type", - Usage: "Content type of attachment.", - InnerField: "content_type", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.filename", - Usage: "Filename of attachment.", - InnerField: "filename", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.url", - Usage: "URL to the attachment.", - InnerField: "url", - OuterIsArrayOfObjects: true, - }, - }, -}) - -var inboxesMessagesGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - }, - Action: handleInboxesMessagesGet, - HideHelpCommand: true, -} - -var inboxesMessagesGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handleInboxesMessagesGetAttachment, - HideHelpCommand: true, -} - -var inboxesMessagesGetRaw = cli.Command{ - Name: "get-raw", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - }, - Action: handleInboxesMessagesGetRaw, - HideHelpCommand: true, -} - -var inboxesMessagesReply = requestflag.WithInnerFlags(cli.Command{ - Name: "reply", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - &requestflag.Flag[any]{ - Name: "attachment", - Usage: "Attachments to include in message.", - BodyPath: "attachments", - }, - &requestflag.Flag[any]{ - Name: "bcc", - BodyPath: "bcc", - }, - &requestflag.Flag[any]{ - Name: "cc", - BodyPath: "cc", - }, - &requestflag.Flag[map[string]any]{ - Name: "headers", - Usage: "Headers to include in message.", - BodyPath: "headers", - }, - &requestflag.Flag[*string]{ - Name: "html", - Usage: "HTML body of message.", - BodyPath: "html", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels of message.", - BodyPath: "labels", - }, - &requestflag.Flag[*bool]{ - Name: "reply-all", - Usage: "Reply to all recipients of the original message.", - BodyPath: "reply_all", - }, - &requestflag.Flag[any]{ - Name: "reply-to", - BodyPath: "reply_to", - }, - &requestflag.Flag[*string]{ - Name: "text", - Usage: "Plain text body of message.", - BodyPath: "text", - }, - &requestflag.Flag[any]{ - Name: "to", - BodyPath: "to", - }, - }, - Action: handleInboxesMessagesReply, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "attachment": { - &requestflag.InnerFlag[*string]{ - Name: "attachment.content", - Usage: "Base64 encoded content of attachment.", - InnerField: "content", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-disposition", - Usage: "Content disposition of attachment.", - InnerField: "content_disposition", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-id", - Usage: "Content ID of attachment.", - InnerField: "content_id", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-type", - Usage: "Content type of attachment.", - InnerField: "content_type", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.filename", - Usage: "Filename of attachment.", - InnerField: "filename", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.url", - Usage: "URL to the attachment.", - InnerField: "url", - OuterIsArrayOfObjects: true, - }, - }, -}) - -var inboxesMessagesReplyAll = requestflag.WithInnerFlags(cli.Command{ - Name: "reply-all", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "message-id", - Usage: "ID of message.", - Required: true, - PathParam: "message_id", - }, - &requestflag.Flag[any]{ - Name: "attachment", - Usage: "Attachments to include in message.", - BodyPath: "attachments", - }, - &requestflag.Flag[map[string]any]{ - Name: "headers", - Usage: "Headers to include in message.", - BodyPath: "headers", - }, - &requestflag.Flag[*string]{ - Name: "html", - Usage: "HTML body of message.", - BodyPath: "html", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels of message.", - BodyPath: "labels", - }, - &requestflag.Flag[any]{ - Name: "reply-to", - BodyPath: "reply_to", - }, - &requestflag.Flag[*string]{ - Name: "text", - Usage: "Plain text body of message.", - BodyPath: "text", - }, - }, - Action: handleInboxesMessagesReplyAll, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "attachment": { - &requestflag.InnerFlag[*string]{ - Name: "attachment.content", - Usage: "Base64 encoded content of attachment.", - InnerField: "content", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-disposition", - Usage: "Content disposition of attachment.", - InnerField: "content_disposition", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-id", - Usage: "Content ID of attachment.", - InnerField: "content_id", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-type", - Usage: "Content type of attachment.", - InnerField: "content_type", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.filename", - Usage: "Filename of attachment.", - InnerField: "filename", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.url", - Usage: "URL to the attachment.", - InnerField: "url", - OuterIsArrayOfObjects: true, - }, - }, -}) - -var inboxesMessagesSearch = cli.Command{ - Name: "search", - Usage: "Full-text search across messages in the inbox, ranked by relevance. The query is\nmatched against the sender, recipients, and subject (substring) and the message\nbody (tokenized full text). Spam, trash, blocked, and unauthenticated messages\nare always excluded. `limit` cannot exceed 100.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "q", - Usage: "Full-text search query. Matched against the sender, recipients, and\nsubject (substring) and the message body (tokenized full text).", - Required: true, - QueryPath: "q", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleInboxesMessagesSearch, - HideHelpCommand: true, -} - -var inboxesMessagesSend = requestflag.WithInnerFlags(cli.Command{ - Name: "send", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[any]{ - Name: "attachment", - Usage: "Attachments to include in message.", - BodyPath: "attachments", - }, - &requestflag.Flag[any]{ - Name: "bcc", - BodyPath: "bcc", - }, - &requestflag.Flag[any]{ - Name: "cc", - BodyPath: "cc", - }, - &requestflag.Flag[map[string]any]{ - Name: "headers", - Usage: "Headers to include in message.", - BodyPath: "headers", - }, - &requestflag.Flag[*string]{ - Name: "html", - Usage: "HTML body of message.", - BodyPath: "html", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels of message.", - BodyPath: "labels", - }, - &requestflag.Flag[any]{ - Name: "reply-to", - BodyPath: "reply_to", - }, - &requestflag.Flag[*string]{ - Name: "subject", - Usage: "Subject of message.", - BodyPath: "subject", - }, - &requestflag.Flag[*string]{ - Name: "text", - Usage: "Plain text body of message.", - BodyPath: "text", - }, - &requestflag.Flag[any]{ - Name: "to", - BodyPath: "to", - }, - }, - Action: handleInboxesMessagesSend, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "attachment": { - &requestflag.InnerFlag[*string]{ - Name: "attachment.content", - Usage: "Base64 encoded content of attachment.", - InnerField: "content", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-disposition", - Usage: "Content disposition of attachment.", - InnerField: "content_disposition", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-id", - Usage: "Content ID of attachment.", - InnerField: "content_id", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.content-type", - Usage: "Content type of attachment.", - InnerField: "content_type", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.filename", - Usage: "Filename of attachment.", - InnerField: "filename", - OuterIsArrayOfObjects: true, - }, - &requestflag.InnerFlag[*string]{ - Name: "attachment.url", - Usage: "URL to the attachment.", - InnerField: "url", - OuterIsArrayOfObjects: true, - }, - }, -}) - -func handleInboxesMessagesUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageUpdateParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.Update( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages update", - Transform: transform, - }) -} - -func handleInboxesMessagesList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.List( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages list", - Transform: transform, - }) -} - -func handleInboxesMessagesForward(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageForwardParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.Forward( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages forward", - Transform: transform, - }) -} - -func handleInboxesMessagesGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageGetParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.Get( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages get", - Transform: transform, - }) -} - -func handleInboxesMessagesGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageGetAttachmentParams{ - InboxID: cmd.Value("inbox-id").(string), - MessageID: cmd.Value("message-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages get-attachment", - Transform: transform, - }) -} - -func handleInboxesMessagesGetRaw(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageGetRawParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.GetRaw( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages get-raw", - Transform: transform, - }) -} - -func handleInboxesMessagesReply(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageReplyParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.Reply( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages reply", - Transform: transform, - }) -} - -func handleInboxesMessagesReplyAll(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("message-id") && len(unusedArgs) > 0 { - cmd.Set("message-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageReplyAllParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.ReplyAll( - ctx, - cmd.Value("message-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages reply-all", - Transform: transform, - }) -} - -func handleInboxesMessagesSearch(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageSearchParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.Search( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages search", - Transform: transform, - }) -} - -func handleInboxesMessagesSend(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxMessageSendParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Messages.Send( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:messages send", - Transform: transform, - }) -} diff --git a/pkg/cmd/inboxmessage_test.go b/pkg/cmd/inboxmessage_test.go deleted file mode 100644 index de21c6d..0000000 --- a/pkg/cmd/inboxmessage_test.go +++ /dev/null @@ -1,434 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" -) - -func TestInboxesMessagesUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "update", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--add-labels", "string", - "--remove-labels", "string", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "add_labels: string\n" + - "remove_labels: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:messages", "update", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - ) - }) -} - -func TestInboxesMessagesList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "list", - "--inbox-id", "inbox_id", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--from", "[string]", - "--include-blocked=true", - "--include-spam=true", - "--include-trash=true", - "--include-unauthenticated=true", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - "--subject", "[string]", - "--to", "[string]", - ) - }) -} - -func TestInboxesMessagesForward(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "forward", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", - "--bcc", "string", - "--cc", "string", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-to", "string", - "--subject", "subject", - "--text", "text", - "--to", "string", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesMessagesForward) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "forward", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment.content", "content", - "--attachment.content-disposition", "inline", - "--attachment.content-id", "content_id", - "--attachment.content-type", "content_type", - "--attachment.filename", "filename", - "--attachment.url", "url", - "--bcc", "string", - "--cc", "string", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-to", "string", - "--subject", "subject", - "--text", "text", - "--to", "string", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "attachments:\n" + - " - content: content\n" + - " content_disposition: inline\n" + - " content_id: content_id\n" + - " content_type: content_type\n" + - " filename: filename\n" + - " url: url\n" + - "bcc: string\n" + - "cc: string\n" + - "headers:\n" + - " foo: string\n" + - "html: html\n" + - "labels:\n" + - " - string\n" + - "reply_to: string\n" + - "subject: subject\n" + - "text: text\n" + - "to: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:messages", "forward", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - ) - }) -} - -func TestInboxesMessagesGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "get", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - ) - }) -} - -func TestInboxesMessagesGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "get-attachment", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment-id", "attachment_id", - ) - }) -} - -func TestInboxesMessagesGetRaw(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "get-raw", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - ) - }) -} - -func TestInboxesMessagesReply(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "reply", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", - "--bcc", "string", - "--cc", "string", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-all=true", - "--reply-to", "string", - "--text", "text", - "--to", "string", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesMessagesReply) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "reply", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment.content", "content", - "--attachment.content-disposition", "inline", - "--attachment.content-id", "content_id", - "--attachment.content-type", "content_type", - "--attachment.filename", "filename", - "--attachment.url", "url", - "--bcc", "string", - "--cc", "string", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-all=true", - "--reply-to", "string", - "--text", "text", - "--to", "string", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "attachments:\n" + - " - content: content\n" + - " content_disposition: inline\n" + - " content_id: content_id\n" + - " content_type: content_type\n" + - " filename: filename\n" + - " url: url\n" + - "bcc: string\n" + - "cc: string\n" + - "headers:\n" + - " foo: string\n" + - "html: html\n" + - "labels:\n" + - " - string\n" + - "reply_all: true\n" + - "reply_to: string\n" + - "text: text\n" + - "to: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:messages", "reply", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - ) - }) -} - -func TestInboxesMessagesReplyAll(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "reply-all", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-to", "string", - "--text", "text", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesMessagesReplyAll) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "reply-all", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - "--attachment.content", "content", - "--attachment.content-disposition", "inline", - "--attachment.content-id", "content_id", - "--attachment.content-type", "content_type", - "--attachment.filename", "filename", - "--attachment.url", "url", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-to", "string", - "--text", "text", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "attachments:\n" + - " - content: content\n" + - " content_disposition: inline\n" + - " content_id: content_id\n" + - " content_type: content_type\n" + - " filename: filename\n" + - " url: url\n" + - "headers:\n" + - " foo: string\n" + - "html: html\n" + - "labels:\n" + - " - string\n" + - "reply_to: string\n" + - "text: text\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:messages", "reply-all", - "--inbox-id", "inbox_id", - "--message-id", "message_id", - ) - }) -} - -func TestInboxesMessagesSearch(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "search", - "--inbox-id", "inbox_id", - "--q", "q", - "--after", "'2019-12-27T18:11:19.117Z'", - "--before", "'2019-12-27T18:11:19.117Z'", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestInboxesMessagesSend(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "send", - "--inbox-id", "inbox_id", - "--attachment", "[{content: content, content_disposition: inline, content_id: content_id, content_type: content_type, filename: filename, url: url}]", - "--bcc", "string", - "--cc", "string", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-to", "string", - "--subject", "subject", - "--text", "text", - "--to", "string", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(inboxesMessagesSend) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:messages", "send", - "--inbox-id", "inbox_id", - "--attachment.content", "content", - "--attachment.content-disposition", "inline", - "--attachment.content-id", "content_id", - "--attachment.content-type", "content_type", - "--attachment.filename", "filename", - "--attachment.url", "url", - "--bcc", "string", - "--cc", "string", - "--headers", "{foo: string}", - "--html", "html", - "--label", "[string]", - "--reply-to", "string", - "--subject", "subject", - "--text", "text", - "--to", "string", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "attachments:\n" + - " - content: content\n" + - " content_disposition: inline\n" + - " content_id: content_id\n" + - " content_type: content_type\n" + - " filename: filename\n" + - " url: url\n" + - "bcc: string\n" + - "cc: string\n" + - "headers:\n" + - " foo: string\n" + - "html: html\n" + - "labels:\n" + - " - string\n" + - "reply_to: string\n" + - "subject: subject\n" + - "text: text\n" + - "to: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "inboxes:messages", "send", - "--inbox-id", "inbox_id", - ) - }) -} diff --git a/pkg/cmd/inboxthread.go b/pkg/cmd/inboxthread.go deleted file mode 100644 index a95892e..0000000 --- a/pkg/cmd/inboxthread.go +++ /dev/null @@ -1,445 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var inboxesThreadsList = cli.Command{ - Name: "list", - Usage: "Lists threads in the inbox, most recent first. Pass `senders`, `recipients`, or\n`subject` to filter by substring. Filtered requests are served by search, which\ncaps `limit` at 100. For relevance-ranked full-text search, use\n`Search Threads`.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*bool]{ - Name: "include-blocked", - Usage: "Include blocked in results.", - QueryPath: "include_blocked", - }, - &requestflag.Flag[*bool]{ - Name: "include-spam", - Usage: "Include spam in results.", - QueryPath: "include_spam", - }, - &requestflag.Flag[*bool]{ - Name: "include-trash", - Usage: "Include trash in results.", - QueryPath: "include_trash", - }, - &requestflag.Flag[*bool]{ - Name: "include-unauthenticated", - Usage: "Include unauthenticated in results.", - QueryPath: "include_unauthenticated", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - &requestflag.Flag[any]{ - Name: "recipient", - Usage: "Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match.", - QueryPath: "recipients", - }, - &requestflag.Flag[any]{ - Name: "sender", - Usage: "Filter to threads whose senders contain this value (substring match). Repeatable; all values must match.", - QueryPath: "senders", - }, - &requestflag.Flag[any]{ - Name: "subject", - Usage: "Filter to threads whose subject contains this value (substring match). Repeatable; all values must match.", - QueryPath: "subject", - }, - }, - Action: handleInboxesThreadsList, - HideHelpCommand: true, -} - -var inboxesThreadsDelete = cli.Command{ - Name: "delete", - Usage: "Permanently deletes a thread and all of its messages.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - }, - Action: handleInboxesThreadsDelete, - HideHelpCommand: true, -} - -var inboxesThreadsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - }, - Action: handleInboxesThreadsGet, - HideHelpCommand: true, -} - -var inboxesThreadsGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handleInboxesThreadsGetAttachment, - HideHelpCommand: true, -} - -var inboxesThreadsSearch = cli.Command{ - Name: "search", - Usage: "Full-text search across threads in the inbox, ranked by relevance. The query is\nmatched against senders, recipients, and subject (substring) and the message\nbody (tokenized full text). Spam, trash, blocked, and unauthenticated threads\nare always excluded. `limit` cannot exceed 100.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[string]{ - Name: "q", - Usage: "Full-text search query. Matched against the sender, recipients, and\nsubject (substring) and the message body (tokenized full text).", - Required: true, - QueryPath: "q", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleInboxesThreadsSearch, - HideHelpCommand: true, -} - -func handleInboxesThreadsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxThreadListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Threads.List( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:threads list", - Transform: transform, - }) -} - -func handleInboxesThreadsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { - cmd.Set("thread-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxThreadDeleteParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - return client.Inboxes.Threads.Delete( - ctx, - cmd.Value("thread-id").(string), - params, - options..., - ) -} - -func handleInboxesThreadsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { - cmd.Set("thread-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxThreadGetParams{ - InboxID: cmd.Value("inbox-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Threads.Get( - ctx, - cmd.Value("thread-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:threads get", - Transform: transform, - }) -} - -func handleInboxesThreadsGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxThreadGetAttachmentParams{ - InboxID: cmd.Value("inbox-id").(string), - ThreadID: cmd.Value("thread-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Threads.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:threads get-attachment", - Transform: transform, - }) -} - -func handleInboxesThreadsSearch(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.InboxThreadSearchParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Inboxes.Threads.Search( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "inboxes:threads search", - Transform: transform, - }) -} diff --git a/pkg/cmd/inboxthread_test.go b/pkg/cmd/inboxthread_test.go deleted file mode 100644 index 2662624..0000000 --- a/pkg/cmd/inboxthread_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestInboxesThreadsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:threads", "list", - "--inbox-id", "inbox_id", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--include-blocked=true", - "--include-spam=true", - "--include-trash=true", - "--include-unauthenticated=true", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - "--recipient", "[string]", - "--sender", "[string]", - "--subject", "[string]", - ) - }) -} - -func TestInboxesThreadsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:threads", "delete", - "--inbox-id", "inbox_id", - "--thread-id", "thread_id", - ) - }) -} - -func TestInboxesThreadsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:threads", "get", - "--inbox-id", "inbox_id", - "--thread-id", "thread_id", - ) - }) -} - -func TestInboxesThreadsGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:threads", "get-attachment", - "--inbox-id", "inbox_id", - "--thread-id", "thread_id", - "--attachment-id", "attachment_id", - ) - }) -} - -func TestInboxesThreadsSearch(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "inboxes:threads", "search", - "--inbox-id", "inbox_id", - "--q", "q", - "--after", "'2019-12-27T18:11:19.117Z'", - "--before", "'2019-12-27T18:11:19.117Z'", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go deleted file mode 100644 index 3431bfd..0000000 --- a/pkg/cmd/list.go +++ /dev/null @@ -1,323 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var listsCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Usage: "Email address or domain to add.", - Required: true, - BodyPath: "entry", - }, - &requestflag.Flag[*string]{ - Name: "reason", - Usage: "Reason for adding the entry.", - BodyPath: "reason", - }, - }, - Action: handleListsCreate, - HideHelpCommand: true, -} - -var listsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleListsList, - HideHelpCommand: true, -} - -var listsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Required: true, - PathParam: "entry", - }, - }, - Action: handleListsDelete, - HideHelpCommand: true, -} - -var listsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Required: true, - PathParam: "entry", - }, - }, - Action: handleListsGet, - HideHelpCommand: true, -} - -func handleListsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("type") && len(unusedArgs) > 0 { - cmd.Set("type", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.ListNewParams{ - Direction: agentmail.ListNewParamsDirection(cmd.Value("direction").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Lists.New( - ctx, - agentmail.ListNewParamsType(cmd.Value("type").(string)), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "lists create", - Transform: transform, - }) -} - -func handleListsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("type") && len(unusedArgs) > 0 { - cmd.Set("type", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.ListListParams{ - Direction: agentmail.ListListParamsDirection(cmd.Value("direction").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Lists.List( - ctx, - agentmail.ListListParamsType(cmd.Value("type").(string)), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "lists list", - Transform: transform, - }) -} - -func handleListsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("entry") && len(unusedArgs) > 0 { - cmd.Set("entry", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.ListDeleteParams{ - Direction: agentmail.ListDeleteParamsDirection(cmd.Value("direction").(string)), - Type: agentmail.ListDeleteParamsType(cmd.Value("type").(string)), - } - - return client.Lists.Delete( - ctx, - cmd.Value("entry").(string), - params, - options..., - ) -} - -func handleListsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("entry") && len(unusedArgs) > 0 { - cmd.Set("entry", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.ListGetParams{ - Direction: agentmail.ListGetParamsDirection(cmd.Value("direction").(string)), - Type: agentmail.ListGetParamsType(cmd.Value("type").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Lists.Get( - ctx, - cmd.Value("entry").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "lists get", - Transform: transform, - }) -} diff --git a/pkg/cmd/list_test.go b/pkg/cmd/list_test.go deleted file mode 100644 index bfd8f51..0000000 --- a/pkg/cmd/list_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestListsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "lists", "create", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - "--reason", "reason", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "entry: entry\n" + - "reason: reason\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "lists", "create", - "--direction", "send", - "--type", "allow", - ) - }) -} - -func TestListsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "lists", "list", - "--direction", "send", - "--type", "allow", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestListsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "lists", "delete", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - ) - }) -} - -func TestListsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "lists", "get", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - ) - }) -} diff --git a/pkg/cmd/organization.go b/pkg/cmd/organization.go deleted file mode 100644 index 1af9601..0000000 --- a/pkg/cmd/organization.go +++ /dev/null @@ -1,62 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var organizationsGet = cli.Command{ - Name: "get", - Usage: "Returns the organization for the authenticated API key (usage limits, counts,\nand billing metadata).", - Suggest: true, - Flags: []cli.Flag{}, - Action: handleOrganizationsGet, - HideHelpCommand: true, -} - -func handleOrganizationsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Organizations.Get(ctx, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "organizations get", - Transform: transform, - }) -} diff --git a/pkg/cmd/organization_test.go b/pkg/cmd/organization_test.go deleted file mode 100644 index 2eb712e..0000000 --- a/pkg/cmd/organization_test.go +++ /dev/null @@ -1,20 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestOrganizationsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "organizations", "get", - ) - }) -} diff --git a/pkg/cmd/pod.go b/pkg/cmd/pod.go deleted file mode 100644 index 59e8f7b..0000000 --- a/pkg/cmd/pod.go +++ /dev/null @@ -1,241 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*string]{ - Name: "client-id", - Usage: "Client ID of pod.", - BodyPath: "client_id", - }, - &requestflag.Flag[*string]{ - Name: "name", - Usage: "Name of pod.", - BodyPath: "name", - }, - }, - Action: handlePodsCreate, - HideHelpCommand: true, -} - -var podsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsList, - HideHelpCommand: true, -} - -var podsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - }, - Action: handlePodsDelete, - HideHelpCommand: true, -} - -var podsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - }, - Action: handlePodsGet, - HideHelpCommand: true, -} - -func handlePodsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods create", - Transform: transform, - }) -} - -func handlePodsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods list", - Transform: transform, - }) -} - -func handlePodsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Pods.Delete(ctx, cmd.Value("pod-id").(string), options...) -} - -func handlePodsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Get(ctx, cmd.Value("pod-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods get", - Transform: transform, - }) -} diff --git a/pkg/cmd/pod_test.go b/pkg/cmd/pod_test.go deleted file mode 100644 index 2be8dc6..0000000 --- a/pkg/cmd/pod_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestPodsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods", "create", - "--client-id", "client_id", - "--name", "name", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "client_id: client_id\n" + - "name: name\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods", "create", - ) - }) -} - -func TestPodsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods", "list", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestPodsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods", "delete", - "--pod-id", "pod_id", - ) - }) -} - -func TestPodsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods", "get", - "--pod-id", "pod_id", - ) - }) -} diff --git a/pkg/cmd/podapikey.go b/pkg/cmd/podapikey.go deleted file mode 100644 index ff85ae4..0000000 --- a/pkg/cmd/podapikey.go +++ /dev/null @@ -1,399 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsAPIKeysCreate = requestflag.WithInnerFlags(cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[*string]{ - Name: "name", - Usage: "Name of api key.", - BodyPath: "name", - }, - &requestflag.Flag[map[string]any]{ - Name: "permissions", - Usage: "Granular permissions for the API key. When ommitted all permissions are granted. Otherwise, only permissions set to true are granted.", - BodyPath: "permissions", - }, - }, - Action: handlePodsAPIKeysCreate, - HideHelpCommand: true, -}, map[string][]requestflag.HasOuterFlag{ - "permissions": { - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-create", - Usage: "Create API keys.", - InnerField: "api_key_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-delete", - Usage: "Delete API keys.", - InnerField: "api_key_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.api-key-read", - Usage: "Read API keys.", - InnerField: "api_key_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-create", - Usage: "Create domains.", - InnerField: "domain_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-delete", - Usage: "Delete domains.", - InnerField: "domain_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-read", - Usage: "Read domain details.", - InnerField: "domain_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.domain-update", - Usage: "Update domains.", - InnerField: "domain_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-create", - Usage: "Create drafts.", - InnerField: "draft_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-delete", - Usage: "Delete drafts.", - InnerField: "draft_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-read", - Usage: "Read drafts.", - InnerField: "draft_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-send", - Usage: "Send drafts.", - InnerField: "draft_send", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.draft-update", - Usage: "Update drafts.", - InnerField: "draft_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-create", - Usage: "Create new inboxes.", - InnerField: "inbox_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-delete", - Usage: "Delete inboxes.", - InnerField: "inbox_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-read", - Usage: "Read inbox details.", - InnerField: "inbox_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.inbox-update", - Usage: "Update inbox settings.", - InnerField: "inbox_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-blocked-read", - Usage: "Access messages labeled blocked.", - InnerField: "label_blocked_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-spam-read", - Usage: "Access messages labeled spam.", - InnerField: "label_spam_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.label-trash-read", - Usage: "Access messages labeled trash.", - InnerField: "label_trash_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-create", - Usage: "Create list entries.", - InnerField: "list_entry_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-delete", - Usage: "Delete list entries.", - InnerField: "list_entry_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.list-entry-read", - Usage: "Read list entries.", - InnerField: "list_entry_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-read", - Usage: "Read messages.", - InnerField: "message_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-send", - Usage: "Send messages.", - InnerField: "message_send", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.message-update", - Usage: "Update message labels.", - InnerField: "message_update", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.metrics-read", - Usage: "Read metrics.", - InnerField: "metrics_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-create", - Usage: "Create pods.", - InnerField: "pod_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-delete", - Usage: "Delete pods.", - InnerField: "pod_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.pod-read", - Usage: "Read pods.", - InnerField: "pod_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.thread-delete", - Usage: "Delete threads.", - InnerField: "thread_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.thread-read", - Usage: "Read threads.", - InnerField: "thread_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-create", - Usage: "Create webhooks.", - InnerField: "webhook_create", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-delete", - Usage: "Delete webhooks.", - InnerField: "webhook_delete", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-read", - Usage: "Read webhook configurations.", - InnerField: "webhook_read", - }, - &requestflag.InnerFlag[*bool]{ - Name: "permissions.webhook-update", - Usage: "Update webhooks.", - InnerField: "webhook_update", - }, - }, -}) - -var podsAPIKeysList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsAPIKeysList, - HideHelpCommand: true, -} - -var podsAPIKeysDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "api-key-id", - Usage: "ID of api key.", - Required: true, - PathParam: "api_key_id", - }, - }, - Action: handlePodsAPIKeysDelete, - HideHelpCommand: true, -} - -func handlePodsAPIKeysCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodAPIKeyNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.APIKeys.New( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:api-keys create", - Transform: transform, - }) -} - -func handlePodsAPIKeysList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodAPIKeyListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.APIKeys.List( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:api-keys list", - Transform: transform, - }) -} - -func handlePodsAPIKeysDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("api-key-id") && len(unusedArgs) > 0 { - cmd.Set("api-key-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodAPIKeyDeleteParams{ - PodID: cmd.Value("pod-id").(string), - } - - return client.Pods.APIKeys.Delete( - ctx, - cmd.Value("api-key-id").(string), - params, - options..., - ) -} diff --git a/pkg/cmd/podapikey_test.go b/pkg/cmd/podapikey_test.go deleted file mode 100644 index 730ea70..0000000 --- a/pkg/cmd/podapikey_test.go +++ /dev/null @@ -1,148 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" -) - -func TestPodsAPIKeysCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:api-keys", "create", - "--pod-id", "pod_id", - "--name", "name", - "--permissions", "{api_key_create: true, api_key_delete: true, api_key_read: true, domain_create: true, domain_delete: true, domain_read: true, domain_update: true, draft_create: true, draft_delete: true, draft_read: true, draft_send: true, draft_update: true, inbox_create: true, inbox_delete: true, inbox_read: true, inbox_update: true, label_blocked_read: true, label_spam_read: true, label_trash_read: true, list_entry_create: true, list_entry_delete: true, list_entry_read: true, message_read: true, message_send: true, message_update: true, metrics_read: true, pod_create: true, pod_delete: true, pod_read: true, thread_delete: true, thread_read: true, webhook_create: true, webhook_delete: true, webhook_read: true, webhook_update: true}", - ) - }) - - t.Run("inner flags", func(t *testing.T) { - // Check that inner flags have been set up correctly - requestflag.CheckInnerFlags(podsAPIKeysCreate) - - // Alternative argument passing style using inner flags - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:api-keys", "create", - "--pod-id", "pod_id", - "--name", "name", - "--permissions.api-key-create=true", - "--permissions.api-key-delete=true", - "--permissions.api-key-read=true", - "--permissions.domain-create=true", - "--permissions.domain-delete=true", - "--permissions.domain-read=true", - "--permissions.domain-update=true", - "--permissions.draft-create=true", - "--permissions.draft-delete=true", - "--permissions.draft-read=true", - "--permissions.draft-send=true", - "--permissions.draft-update=true", - "--permissions.inbox-create=true", - "--permissions.inbox-delete=true", - "--permissions.inbox-read=true", - "--permissions.inbox-update=true", - "--permissions.label-blocked-read=true", - "--permissions.label-spam-read=true", - "--permissions.label-trash-read=true", - "--permissions.list-entry-create=true", - "--permissions.list-entry-delete=true", - "--permissions.list-entry-read=true", - "--permissions.message-read=true", - "--permissions.message-send=true", - "--permissions.message-update=true", - "--permissions.metrics-read=true", - "--permissions.pod-create=true", - "--permissions.pod-delete=true", - "--permissions.pod-read=true", - "--permissions.thread-delete=true", - "--permissions.thread-read=true", - "--permissions.webhook-create=true", - "--permissions.webhook-delete=true", - "--permissions.webhook-read=true", - "--permissions.webhook-update=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "name: name\n" + - "permissions:\n" + - " api_key_create: true\n" + - " api_key_delete: true\n" + - " api_key_read: true\n" + - " domain_create: true\n" + - " domain_delete: true\n" + - " domain_read: true\n" + - " domain_update: true\n" + - " draft_create: true\n" + - " draft_delete: true\n" + - " draft_read: true\n" + - " draft_send: true\n" + - " draft_update: true\n" + - " inbox_create: true\n" + - " inbox_delete: true\n" + - " inbox_read: true\n" + - " inbox_update: true\n" + - " label_blocked_read: true\n" + - " label_spam_read: true\n" + - " label_trash_read: true\n" + - " list_entry_create: true\n" + - " list_entry_delete: true\n" + - " list_entry_read: true\n" + - " message_read: true\n" + - " message_send: true\n" + - " message_update: true\n" + - " metrics_read: true\n" + - " pod_create: true\n" + - " pod_delete: true\n" + - " pod_read: true\n" + - " thread_delete: true\n" + - " thread_read: true\n" + - " webhook_create: true\n" + - " webhook_delete: true\n" + - " webhook_read: true\n" + - " webhook_update: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods:api-keys", "create", - "--pod-id", "pod_id", - ) - }) -} - -func TestPodsAPIKeysList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:api-keys", "list", - "--pod-id", "pod_id", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestPodsAPIKeysDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:api-keys", "delete", - "--pod-id", "pod_id", - "--api-key-id", "api_key_id", - ) - }) -} diff --git a/pkg/cmd/poddomain.go b/pkg/cmd/poddomain.go deleted file mode 100644 index 9eda6e9..0000000 --- a/pkg/cmd/poddomain.go +++ /dev/null @@ -1,500 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsDomainsCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "domain", - Usage: "The name of the domain (e.g., `example.com`).", - Required: true, - BodyPath: "domain", - }, - &requestflag.Flag[*bool]{ - Name: "feedback-enabled", - Usage: "Bounce and complaint notifications are sent to your inboxes.", - BodyPath: "feedback_enabled", - }, - &requestflag.Flag[*bool]{ - Name: "subdomains-enabled", - Usage: "Allow inboxes on any subdomain of this domain. Adds a required wildcard MX\nrecord (`*.`) to `records`.", - BodyPath: "subdomains_enabled", - }, - }, - Action: handlePodsDomainsCreate, - HideHelpCommand: true, -} - -var podsDomainsUpdate = cli.Command{ - Name: "update", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - &requestflag.Flag[*bool]{ - Name: "feedback-enabled", - Usage: "Bounce and complaint notifications are sent to your inboxes.", - BodyPath: "feedback_enabled", - }, - &requestflag.Flag[*bool]{ - Name: "subdomains-enabled", - Usage: "Allow inboxes on any subdomain of this domain. Adds a required wildcard MX\nrecord (`*.`) to `records`.", - BodyPath: "subdomains_enabled", - }, - }, - Action: handlePodsDomainsUpdate, - HideHelpCommand: true, -} - -var podsDomainsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsDomainsList, - HideHelpCommand: true, -} - -var podsDomainsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handlePodsDomainsDelete, - HideHelpCommand: true, -} - -var podsDomainsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handlePodsDomainsGet, - HideHelpCommand: true, -} - -var podsDomainsGetZoneFile = cli.Command{ - Name: "get-zone-file", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handlePodsDomainsGetZoneFile, - HideHelpCommand: true, -} - -var podsDomainsVerify = cli.Command{ - Name: "verify", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "domain-id", - Usage: "The ID of the domain.", - Required: true, - PathParam: "domain_id", - }, - }, - Action: handlePodsDomainsVerify, - HideHelpCommand: true, -} - -func handlePodsDomainsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Domains.New( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:domains create", - Transform: transform, - }) -} - -func handlePodsDomainsUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainUpdateParams{ - PodID: cmd.Value("pod-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Domains.Update( - ctx, - cmd.Value("domain-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:domains update", - Transform: transform, - }) -} - -func handlePodsDomainsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Domains.List( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:domains list", - Transform: transform, - }) -} - -func handlePodsDomainsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainDeleteParams{ - PodID: cmd.Value("pod-id").(string), - } - - return client.Pods.Domains.Delete( - ctx, - cmd.Value("domain-id").(string), - params, - options..., - ) -} - -func handlePodsDomainsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainGetParams{ - PodID: cmd.Value("pod-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Domains.Get( - ctx, - cmd.Value("domain-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:domains get", - Transform: transform, - }) -} - -func handlePodsDomainsGetZoneFile(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainGetZoneFileParams{ - PodID: cmd.Value("pod-id").(string), - } - - return client.Pods.Domains.GetZoneFile( - ctx, - cmd.Value("domain-id").(string), - params, - options..., - ) -} - -func handlePodsDomainsVerify(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("domain-id") && len(unusedArgs) > 0 { - cmd.Set("domain-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDomainVerifyParams{ - PodID: cmd.Value("pod-id").(string), - } - - return client.Pods.Domains.Verify( - ctx, - cmd.Value("domain-id").(string), - params, - options..., - ) -} diff --git a/pkg/cmd/poddomain_test.go b/pkg/cmd/poddomain_test.go deleted file mode 100644 index fb8caeb..0000000 --- a/pkg/cmd/poddomain_test.go +++ /dev/null @@ -1,134 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestPodsDomainsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "create", - "--pod-id", "pod_id", - "--domain", "domain", - "--feedback-enabled=true", - "--subdomains-enabled=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "domain: domain\n" + - "feedback_enabled: true\n" + - "subdomains_enabled: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods:domains", "create", - "--pod-id", "pod_id", - ) - }) -} - -func TestPodsDomainsUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "update", - "--pod-id", "pod_id", - "--domain-id", "domain_id", - "--feedback-enabled=true", - "--subdomains-enabled=true", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "feedback_enabled: true\n" + - "subdomains_enabled: true\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods:domains", "update", - "--pod-id", "pod_id", - "--domain-id", "domain_id", - ) - }) -} - -func TestPodsDomainsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "list", - "--pod-id", "pod_id", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestPodsDomainsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "delete", - "--pod-id", "pod_id", - "--domain-id", "domain_id", - ) - }) -} - -func TestPodsDomainsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "get", - "--pod-id", "pod_id", - "--domain-id", "domain_id", - ) - }) -} - -func TestPodsDomainsGetZoneFile(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "get-zone-file", - "--pod-id", "pod_id", - "--domain-id", "domain_id", - ) - }) -} - -func TestPodsDomainsVerify(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:domains", "verify", - "--pod-id", "pod_id", - "--domain-id", "domain_id", - ) - }) -} diff --git a/pkg/cmd/poddraft.go b/pkg/cmd/poddraft.go deleted file mode 100644 index ae61c90..0000000 --- a/pkg/cmd/poddraft.go +++ /dev/null @@ -1,263 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsDraftsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsDraftsList, - HideHelpCommand: true, -} - -var podsDraftsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - }, - Action: handlePodsDraftsGet, - HideHelpCommand: true, -} - -var podsDraftsGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "draft-id", - Usage: "ID of draft.", - Required: true, - PathParam: "draft_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handlePodsDraftsGetAttachment, - HideHelpCommand: true, -} - -func handlePodsDraftsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDraftListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Drafts.List( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:drafts list", - Transform: transform, - }) -} - -func handlePodsDraftsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("draft-id") && len(unusedArgs) > 0 { - cmd.Set("draft-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDraftGetParams{ - PodID: cmd.Value("pod-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Drafts.Get( - ctx, - cmd.Value("draft-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:drafts get", - Transform: transform, - }) -} - -func handlePodsDraftsGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodDraftGetAttachmentParams{ - PodID: cmd.Value("pod-id").(string), - DraftID: cmd.Value("draft-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Drafts.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:drafts get-attachment", - Transform: transform, - }) -} diff --git a/pkg/cmd/poddraft_test.go b/pkg/cmd/poddraft_test.go deleted file mode 100644 index a86b43f..0000000 --- a/pkg/cmd/poddraft_test.go +++ /dev/null @@ -1,54 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestPodsDraftsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:drafts", "list", - "--pod-id", "pod_id", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestPodsDraftsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:drafts", "get", - "--pod-id", "pod_id", - "--draft-id", "draft_id", - ) - }) -} - -func TestPodsDraftsGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:drafts", "get-attachment", - "--pod-id", "pod_id", - "--draft-id", "draft_id", - "--attachment-id", "attachment_id", - ) - }) -} diff --git a/pkg/cmd/podinbox.go b/pkg/cmd/podinbox.go deleted file mode 100644 index a25d841..0000000 --- a/pkg/cmd/podinbox.go +++ /dev/null @@ -1,397 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsInboxesCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[*string]{ - Name: "client-id", - Usage: "Client ID of inbox.", - BodyPath: "client_id", - }, - &requestflag.Flag[*string]{ - Name: "display-name", - Usage: "Display name: `Display Name `.", - BodyPath: "display_name", - }, - &requestflag.Flag[*string]{ - Name: "domain", - Usage: "Domain of address. Must be a verified domain, or any subdomain of a\nverified domain that has subdomains enabled (e.g., `bot.example.com`).\nDefaults to `agentmail.to`.", - BodyPath: "domain", - }, - &requestflag.Flag[map[string]any]{ - Name: "metadata", - Usage: "Custom metadata to attach to the inbox.", - BodyPath: "metadata", - }, - &requestflag.Flag[*string]{ - Name: "username", - Usage: "Username of address. Randomly generated if not specified.", - BodyPath: "username", - }, - }, - Action: handlePodsInboxesCreate, - HideHelpCommand: true, -} - -var podsInboxesUpdate = cli.Command{ - Name: "update", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - &requestflag.Flag[*string]{ - Name: "display-name", - Usage: "Display name: `Display Name `.", - BodyPath: "display_name", - }, - &requestflag.Flag[map[string]any]{ - Name: "metadata", - Usage: "Metadata to merge into the inbox's existing metadata. Keys you include\nare added or overwritten; keys you omit are left unchanged. To remove a\nsingle key, send it with a null value. To clear all metadata, send\n`metadata` as null. Sending an empty object is rejected; use null to\nclear. Each update must include at least one of `display_name` or\n`metadata`.", - BodyPath: "metadata", - }, - }, - Action: handlePodsInboxesUpdate, - HideHelpCommand: true, -} - -var podsInboxesList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsInboxesList, - HideHelpCommand: true, -} - -var podsInboxesDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - }, - Action: handlePodsInboxesDelete, - HideHelpCommand: true, -} - -var podsInboxesGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "inbox-id", - Usage: "The ID of the inbox.", - Required: true, - PathParam: "inbox_id", - }, - }, - Action: handlePodsInboxesGet, - HideHelpCommand: true, -} - -func handlePodsInboxesCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodInboxNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Inboxes.New( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:inboxes create", - Transform: transform, - }) -} - -func handlePodsInboxesUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodInboxUpdateParams{ - PodID: cmd.Value("pod-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Inboxes.Update( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:inboxes update", - Transform: transform, - }) -} - -func handlePodsInboxesList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodInboxListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Inboxes.List( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:inboxes list", - Transform: transform, - }) -} - -func handlePodsInboxesDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodInboxDeleteParams{ - PodID: cmd.Value("pod-id").(string), - } - - return client.Pods.Inboxes.Delete( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) -} - -func handlePodsInboxesGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("inbox-id") && len(unusedArgs) > 0 { - cmd.Set("inbox-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodInboxGetParams{ - PodID: cmd.Value("pod-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Inboxes.Get( - ctx, - cmd.Value("inbox-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:inboxes get", - Transform: transform, - }) -} diff --git a/pkg/cmd/podinbox_test.go b/pkg/cmd/podinbox_test.go deleted file mode 100644 index d7de7fb..0000000 --- a/pkg/cmd/podinbox_test.go +++ /dev/null @@ -1,114 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestPodsInboxesCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:inboxes", "create", - "--pod-id", "pod_id", - "--client-id", "client_id", - "--display-name", "display_name", - "--domain", "domain", - "--metadata", "{foo: string}", - "--username", "username", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "client_id: client_id\n" + - "display_name: display_name\n" + - "domain: domain\n" + - "metadata:\n" + - " foo: string\n" + - "username: username\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods:inboxes", "create", - "--pod-id", "pod_id", - ) - }) -} - -func TestPodsInboxesUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:inboxes", "update", - "--pod-id", "pod_id", - "--inbox-id", "inbox_id", - "--display-name", "display_name", - "--metadata", "{foo: string}", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "display_name: display_name\n" + - "metadata:\n" + - " foo: string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods:inboxes", "update", - "--pod-id", "pod_id", - "--inbox-id", "inbox_id", - ) - }) -} - -func TestPodsInboxesList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:inboxes", "list", - "--pod-id", "pod_id", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestPodsInboxesDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:inboxes", "delete", - "--pod-id", "pod_id", - "--inbox-id", "inbox_id", - ) - }) -} - -func TestPodsInboxesGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:inboxes", "get", - "--pod-id", "pod_id", - "--inbox-id", "inbox_id", - ) - }) -} diff --git a/pkg/cmd/podlist.go b/pkg/cmd/podlist.go deleted file mode 100644 index c720b84..0000000 --- a/pkg/cmd/podlist.go +++ /dev/null @@ -1,351 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsListsCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Usage: "Email address or domain to add.", - Required: true, - BodyPath: "entry", - }, - &requestflag.Flag[*string]{ - Name: "reason", - Usage: "Reason for adding the entry.", - BodyPath: "reason", - }, - }, - Action: handlePodsListsCreate, - HideHelpCommand: true, -} - -var podsListsList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsListsList, - HideHelpCommand: true, -} - -var podsListsDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Required: true, - PathParam: "entry", - }, - }, - Action: handlePodsListsDelete, - HideHelpCommand: true, -} - -var podsListsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "direction", - Usage: "Direction of list entry.", - Required: true, - PathParam: "direction", - }, - &requestflag.Flag[string]{ - Name: "type", - Usage: "Type of list entry.", - Required: true, - PathParam: "type", - }, - &requestflag.Flag[string]{ - Name: "entry", - Required: true, - PathParam: "entry", - }, - }, - Action: handlePodsListsGet, - HideHelpCommand: true, -} - -func handlePodsListsCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("type") && len(unusedArgs) > 0 { - cmd.Set("type", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodListNewParams{ - PodID: cmd.Value("pod-id").(string), - Direction: agentmail.PodListNewParamsDirection(cmd.Value("direction").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Lists.New( - ctx, - agentmail.PodListNewParamsType(cmd.Value("type").(string)), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:lists create", - Transform: transform, - }) -} - -func handlePodsListsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("type") && len(unusedArgs) > 0 { - cmd.Set("type", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodListListParams{ - PodID: cmd.Value("pod-id").(string), - Direction: agentmail.PodListListParamsDirection(cmd.Value("direction").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Lists.List( - ctx, - agentmail.PodListListParamsType(cmd.Value("type").(string)), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:lists list", - Transform: transform, - }) -} - -func handlePodsListsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("entry") && len(unusedArgs) > 0 { - cmd.Set("entry", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodListDeleteParams{ - PodID: cmd.Value("pod-id").(string), - Direction: agentmail.PodListDeleteParamsDirection(cmd.Value("direction").(string)), - Type: agentmail.PodListDeleteParamsType(cmd.Value("type").(string)), - } - - return client.Pods.Lists.Delete( - ctx, - cmd.Value("entry").(string), - params, - options..., - ) -} - -func handlePodsListsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("entry") && len(unusedArgs) > 0 { - cmd.Set("entry", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodListGetParams{ - PodID: cmd.Value("pod-id").(string), - Direction: agentmail.PodListGetParamsDirection(cmd.Value("direction").(string)), - Type: agentmail.PodListGetParamsType(cmd.Value("type").(string)), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Lists.Get( - ctx, - cmd.Value("entry").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:lists get", - Transform: transform, - }) -} diff --git a/pkg/cmd/podlist_test.go b/pkg/cmd/podlist_test.go deleted file mode 100644 index 85162b7..0000000 --- a/pkg/cmd/podlist_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestPodsListsCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:lists", "create", - "--pod-id", "pod_id", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - "--reason", "reason", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "entry: entry\n" + - "reason: reason\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "pods:lists", "create", - "--pod-id", "pod_id", - "--direction", "send", - "--type", "allow", - ) - }) -} - -func TestPodsListsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:lists", "list", - "--pod-id", "pod_id", - "--direction", "send", - "--type", "allow", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestPodsListsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:lists", "delete", - "--pod-id", "pod_id", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - ) - }) -} - -func TestPodsListsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:lists", "get", - "--pod-id", "pod_id", - "--direction", "send", - "--type", "allow", - "--entry", "entry", - ) - }) -} diff --git a/pkg/cmd/podthread.go b/pkg/cmd/podthread.go deleted file mode 100644 index 548f1fd..0000000 --- a/pkg/cmd/podthread.go +++ /dev/null @@ -1,445 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var podsThreadsList = cli.Command{ - Name: "list", - Usage: "Lists threads in the pod, most recent first. Pass `senders`, `recipients`, or\n`subject` to filter by substring. Filtered requests are served by search, which\ncaps `limit` at 100. For relevance-ranked full-text search, use\n`Search Threads`.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*bool]{ - Name: "include-blocked", - Usage: "Include blocked in results.", - QueryPath: "include_blocked", - }, - &requestflag.Flag[*bool]{ - Name: "include-spam", - Usage: "Include spam in results.", - QueryPath: "include_spam", - }, - &requestflag.Flag[*bool]{ - Name: "include-trash", - Usage: "Include trash in results.", - QueryPath: "include_trash", - }, - &requestflag.Flag[*bool]{ - Name: "include-unauthenticated", - Usage: "Include unauthenticated in results.", - QueryPath: "include_unauthenticated", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - &requestflag.Flag[any]{ - Name: "recipient", - Usage: "Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match.", - QueryPath: "recipients", - }, - &requestflag.Flag[any]{ - Name: "sender", - Usage: "Filter to threads whose senders contain this value (substring match). Repeatable; all values must match.", - QueryPath: "senders", - }, - &requestflag.Flag[any]{ - Name: "subject", - Usage: "Filter to threads whose subject contains this value (substring match). Repeatable; all values must match.", - QueryPath: "subject", - }, - }, - Action: handlePodsThreadsList, - HideHelpCommand: true, -} - -var podsThreadsDelete = cli.Command{ - Name: "delete", - Usage: "Permanently deletes a thread and all of its messages.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - }, - Action: handlePodsThreadsDelete, - HideHelpCommand: true, -} - -var podsThreadsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - }, - Action: handlePodsThreadsGet, - HideHelpCommand: true, -} - -var podsThreadsGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handlePodsThreadsGetAttachment, - HideHelpCommand: true, -} - -var podsThreadsSearch = cli.Command{ - Name: "search", - Usage: "Full-text search across threads in the pod, ranked by relevance. The query is\nmatched against senders, recipients, and subject (substring) and the message\nbody (tokenized full text). Spam, trash, blocked, and unauthenticated threads\nare always excluded. `limit` cannot exceed 100.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "pod-id", - Usage: "ID of pod.", - Required: true, - PathParam: "pod_id", - }, - &requestflag.Flag[string]{ - Name: "q", - Usage: "Full-text search query. Matched against the sender, recipients, and\nsubject (substring) and the message body (tokenized full text).", - Required: true, - QueryPath: "q", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handlePodsThreadsSearch, - HideHelpCommand: true, -} - -func handlePodsThreadsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodThreadListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Threads.List( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:threads list", - Transform: transform, - }) -} - -func handlePodsThreadsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { - cmd.Set("thread-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodThreadDeleteParams{ - PodID: cmd.Value("pod-id").(string), - } - - return client.Pods.Threads.Delete( - ctx, - cmd.Value("thread-id").(string), - params, - options..., - ) -} - -func handlePodsThreadsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { - cmd.Set("thread-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodThreadGetParams{ - PodID: cmd.Value("pod-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Threads.Get( - ctx, - cmd.Value("thread-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:threads get", - Transform: transform, - }) -} - -func handlePodsThreadsGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodThreadGetAttachmentParams{ - PodID: cmd.Value("pod-id").(string), - ThreadID: cmd.Value("thread-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Threads.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:threads get-attachment", - Transform: transform, - }) -} - -func handlePodsThreadsSearch(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("pod-id") && len(unusedArgs) > 0 { - cmd.Set("pod-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.PodThreadSearchParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Pods.Threads.Search( - ctx, - cmd.Value("pod-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "pods:threads search", - Transform: transform, - }) -} diff --git a/pkg/cmd/podthread_test.go b/pkg/cmd/podthread_test.go deleted file mode 100644 index d9849b6..0000000 --- a/pkg/cmd/podthread_test.go +++ /dev/null @@ -1,91 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestPodsThreadsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:threads", "list", - "--pod-id", "pod_id", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--include-blocked=true", - "--include-spam=true", - "--include-trash=true", - "--include-unauthenticated=true", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - "--recipient", "[string]", - "--sender", "[string]", - "--subject", "[string]", - ) - }) -} - -func TestPodsThreadsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:threads", "delete", - "--pod-id", "pod_id", - "--thread-id", "thread_id", - ) - }) -} - -func TestPodsThreadsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:threads", "get", - "--pod-id", "pod_id", - "--thread-id", "thread_id", - ) - }) -} - -func TestPodsThreadsGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:threads", "get-attachment", - "--pod-id", "pod_id", - "--thread-id", "thread_id", - "--attachment-id", "attachment_id", - ) - }) -} - -func TestPodsThreadsSearch(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "pods:threads", "search", - "--pod-id", "pod_id", - "--q", "q", - "--after", "'2019-12-27T18:11:19.117Z'", - "--before", "'2019-12-27T18:11:19.117Z'", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} diff --git a/pkg/cmd/suggest.go b/pkg/cmd/suggest.go deleted file mode 100644 index b4b637c..0000000 --- a/pkg/cmd/suggest.go +++ /dev/null @@ -1,126 +0,0 @@ -package cmd - -import ( - "fmt" - "math" - "slices" - "strings" - - "github.com/urfave/cli/v3" -) - -// This entire file is mostly taken from urfave/cli/v3's source, with the exception of suggestCommand which is -// modified for a nicer error message. - -// jaroDistance is the measure of similarity between two strings. It returns a -// value between 0 and 1, where 1 indicates identical strings and 0 indicates -// completely different strings. -// -// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro.go. -func jaroDistance(a, b string) float64 { - if len(a) == 0 && len(b) == 0 { - return 1 - } - if len(a) == 0 || len(b) == 0 { - return 0 - } - - lenA := float64(len(a)) - lenB := float64(len(b)) - hashA := make([]bool, len(a)) - hashB := make([]bool, len(b)) - maxDistance := int(math.Max(0, math.Floor(math.Max(lenA, lenB)/2.0)-1)) - - var matches float64 - for i := 0; i < len(a); i++ { - start := int(math.Max(0, float64(i-maxDistance))) - end := int(math.Min(lenB-1, float64(i+maxDistance))) - - for j := start; j <= end; j++ { - if hashB[j] { - continue - } - if a[i] == b[j] { - hashA[i] = true - hashB[j] = true - matches++ - break - } - } - } - if matches == 0 { - return 0 - } - - var transpositions float64 - var j int - for i := 0; i < len(a); i++ { - if !hashA[i] { - continue - } - for !hashB[j] { - j++ - } - if a[i] != b[j] { - transpositions++ - } - j++ - } - - transpositions /= 2 - return ((matches / lenA) + (matches / lenB) + ((matches - transpositions) / matches)) / 3.0 -} - -// jaroWinkler is more accurate when strings have a common prefix up to a -// defined maximum length. -// -// Adapted from https://github.com/xrash/smetrics/blob/5f08fbb34913bc8ab95bb4f2a89a0637ca922666/jaro-winkler.go. -func jaroWinkler(a, b string) float64 { - const ( - boostThreshold = 0.7 - prefixSize = 4 - ) - jaroDist := jaroDistance(a, b) - if jaroDist <= boostThreshold { - return jaroDist - } - - prefix := int(math.Min(float64(len(a)), math.Min(float64(prefixSize), float64(len(b))))) - - var prefixMatch float64 - for i := 0; i < prefix; i++ { - if a[i] == b[i] { - prefixMatch++ - } else { - break - } - } - return jaroDist + 0.1*prefixMatch*(1.0-jaroDist) -} - -// suggestCommand takes a list of commands and a provided string to suggest a -// command name -func suggestCommand(commands []*cli.Command, provided string) string { - distance := 0.0 - var lineage []*cli.Command - for _, command := range commands { - for _, name := range command.Names() { - newDistance := jaroWinkler(name, provided) - if newDistance > distance { - distance = newDistance - lineage = command.Lineage() - } - } - } - - var parts []string - for _, command := range lineage { - parts = append(parts, command.Name) - } - slices.Reverse(parts) - return fmt.Sprintf("Did you mean '%s'?", strings.Join(parts, " ")) -} - -func init() { - cli.SuggestCommand = suggestCommand -} diff --git a/pkg/cmd/thread.go b/pkg/cmd/thread.go deleted file mode 100644 index 787543e..0000000 --- a/pkg/cmd/thread.go +++ /dev/null @@ -1,380 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var threadsList = cli.Command{ - Name: "list", - Usage: "Lists threads, most recent first. Pass `senders`, `recipients`, or `subject` to\nfilter by substring. Filtered requests are served by search, which caps `limit`\nat 100. For relevance-ranked full-text search across senders, recipients,\nsubject, and message body, use `Search Threads`.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*bool]{ - Name: "include-blocked", - Usage: "Include blocked in results.", - QueryPath: "include_blocked", - }, - &requestflag.Flag[*bool]{ - Name: "include-spam", - Usage: "Include spam in results.", - QueryPath: "include_spam", - }, - &requestflag.Flag[*bool]{ - Name: "include-trash", - Usage: "Include trash in results.", - QueryPath: "include_trash", - }, - &requestflag.Flag[*bool]{ - Name: "include-unauthenticated", - Usage: "Include unauthenticated in results.", - QueryPath: "include_unauthenticated", - }, - &requestflag.Flag[any]{ - Name: "label", - Usage: "Labels to filter by.", - QueryPath: "labels", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - &requestflag.Flag[any]{ - Name: "recipient", - Usage: "Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match.", - QueryPath: "recipients", - }, - &requestflag.Flag[any]{ - Name: "sender", - Usage: "Filter to threads whose senders contain this value (substring match). Repeatable; all values must match.", - QueryPath: "senders", - }, - &requestflag.Flag[any]{ - Name: "subject", - Usage: "Filter to threads whose subject contains this value (substring match). Repeatable; all values must match.", - QueryPath: "subject", - }, - }, - Action: handleThreadsList, - HideHelpCommand: true, -} - -var threadsDelete = cli.Command{ - Name: "delete", - Usage: "Permanently deletes a thread and all of its messages.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - }, - Action: handleThreadsDelete, - HideHelpCommand: true, -} - -var threadsGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - }, - Action: handleThreadsGet, - HideHelpCommand: true, -} - -var threadsGetAttachment = cli.Command{ - Name: "get-attachment", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "thread-id", - Usage: "ID of thread.", - Required: true, - PathParam: "thread_id", - }, - &requestflag.Flag[string]{ - Name: "attachment-id", - Usage: "ID of attachment.", - Required: true, - PathParam: "attachment_id", - }, - }, - Action: handleThreadsGetAttachment, - HideHelpCommand: true, -} - -var threadsSearch = cli.Command{ - Name: "search", - Usage: "Full-text search across threads in the organization, ranked by relevance. The\nquery is matched against senders, recipients, and subject (substring) and the\nmessage body (tokenized full text). Spam, trash, blocked, and unauthenticated\nthreads are always excluded. `limit` cannot exceed 100.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "q", - Usage: "Full-text search query. Matched against the sender, recipients, and\nsubject (substring) and the message body (tokenized full text).", - Required: true, - QueryPath: "q", - }, - &requestflag.Flag[any]{ - Name: "after", - Usage: "Timestamp after which to filter by.", - QueryPath: "after", - }, - &requestflag.Flag[any]{ - Name: "before", - Usage: "Timestamp before which to filter by.", - QueryPath: "before", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleThreadsSearch, - HideHelpCommand: true, -} - -func handleThreadsList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.ThreadListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Threads.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "threads list", - Transform: transform, - }) -} - -func handleThreadsDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { - cmd.Set("thread-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Threads.Delete(ctx, cmd.Value("thread-id").(string), options...) -} - -func handleThreadsGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("thread-id") && len(unusedArgs) > 0 { - cmd.Set("thread-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Threads.Get(ctx, cmd.Value("thread-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "threads get", - Transform: transform, - }) -} - -func handleThreadsGetAttachment(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("attachment-id") && len(unusedArgs) > 0 { - cmd.Set("attachment-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.ThreadGetAttachmentParams{ - ThreadID: cmd.Value("thread-id").(string), - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Threads.GetAttachment( - ctx, - cmd.Value("attachment-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "threads get-attachment", - Transform: transform, - }) -} - -func handleThreadsSearch(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.ThreadSearchParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Threads.Search(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "threads search", - Transform: transform, - }) -} diff --git a/pkg/cmd/thread_test.go b/pkg/cmd/thread_test.go deleted file mode 100644 index 5d85452..0000000 --- a/pkg/cmd/thread_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestThreadsList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "threads", "list", - "--after", "'2019-12-27T18:11:19.117Z'", - "--ascending=true", - "--before", "'2019-12-27T18:11:19.117Z'", - "--include-blocked=true", - "--include-spam=true", - "--include-trash=true", - "--include-unauthenticated=true", - "--label", "[string]", - "--limit", "0", - "--page-token", "page_token", - "--recipient", "[string]", - "--sender", "[string]", - "--subject", "[string]", - ) - }) -} - -func TestThreadsDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "threads", "delete", - "--thread-id", "thread_id", - ) - }) -} - -func TestThreadsGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "threads", "get", - "--thread-id", "thread_id", - ) - }) -} - -func TestThreadsGetAttachment(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "threads", "get-attachment", - "--thread-id", "thread_id", - "--attachment-id", "attachment_id", - ) - }) -} - -func TestThreadsSearch(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "threads", "search", - "--q", "q", - "--after", "'2019-12-27T18:11:19.117Z'", - "--before", "'2019-12-27T18:11:19.117Z'", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} diff --git a/pkg/cmd/version.go b/pkg/cmd/version.go deleted file mode 100644 index 6e914c7..0000000 --- a/pkg/cmd/version.go +++ /dev/null @@ -1,5 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -const Version = "0.7.14" // x-release-please-version diff --git a/pkg/cmd/webhook.go b/pkg/cmd/webhook.go deleted file mode 100644 index 9d8b9a6..0000000 --- a/pkg/cmd/webhook.go +++ /dev/null @@ -1,333 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "context" - "fmt" - - "github.com/agentmail-to/agentmail-cli/internal/apiquery" - "github.com/agentmail-to/agentmail-cli/internal/requestflag" - "github.com/agentmail-to/agentmail-go" - "github.com/agentmail-to/agentmail-go/option" - "github.com/tidwall/gjson" - "github.com/urfave/cli/v3" -) - -var webhooksCreate = cli.Command{ - Name: "create", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[[]string]{ - Name: "event-type", - Usage: "Full list of event types this webhook should receive. At least one type is required. Send every type you\nwant in this array (not incremental). See [Webhooks overview](https://docs.agentmail.to/webhooks-overview)\nfor spam, blocked, and unauthenticated events and required permissions.", - Required: true, - BodyPath: "event_types", - }, - &requestflag.Flag[string]{ - Name: "url", - Usage: "URL of webhook endpoint.", - Required: true, - BodyPath: "url", - }, - &requestflag.Flag[*string]{ - Name: "client-id", - Usage: "Client ID of webhook.", - BodyPath: "client_id", - }, - &requestflag.Flag[any]{ - Name: "pod-id", - Usage: "Pods for which to send events. Maximum 10 per webhook.", - BodyPath: "pod_ids", - }, - }, - Action: handleWebhooksCreate, - HideHelpCommand: true, -} - -var webhooksUpdate = cli.Command{ - Name: "update", - Usage: "Update inbox or pod subscriptions, or replace the webhook's `event_types` in\nfull when you pass a non-empty `event_types` array (see request field docs).\nInbox and pod changes use add/remove lists.", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "webhook-id", - Usage: "ID of webhook.", - Required: true, - PathParam: "webhook_id", - }, - &requestflag.Flag[any]{ - Name: "add-pod-id", - Usage: "Pod IDs to subscribe to the webhook.", - BodyPath: "add_pod_ids", - }, - &requestflag.Flag[any]{ - Name: "event-type", - Usage: "When you send a non-empty list, it replaces the webhook's subscribed event types in full (the same\n\"set the list\" behavior as create). It is not a merge or diff: include every event type you want after\nthe update. Sending a one-element array means the webhook will only receive that one type afterward.\nOmit this field or send an empty array to leave event types unchanged. Clearing all types with an empty\nlist is not supported. Subscribing to `message.received.spam`, `message.received.blocked`, or\n`message.received.unauthenticated` requires the matching label permission on the API key.", - BodyPath: "event_types", - }, - &requestflag.Flag[any]{ - Name: "remove-pod-id", - Usage: "Pod IDs to unsubscribe from the webhook.", - BodyPath: "remove_pod_ids", - }, - }, - Action: handleWebhooksUpdate, - HideHelpCommand: true, -} - -var webhooksList = cli.Command{ - Name: "list", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[*bool]{ - Name: "ascending", - Usage: "Sort in ascending temporal order.", - QueryPath: "ascending", - }, - &requestflag.Flag[*int64]{ - Name: "limit", - Usage: "Limit of number of items returned.", - QueryPath: "limit", - }, - &requestflag.Flag[*string]{ - Name: "page-token", - Usage: "Page token for pagination.", - QueryPath: "page_token", - }, - }, - Action: handleWebhooksList, - HideHelpCommand: true, -} - -var webhooksDelete = cli.Command{ - Name: "delete", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "webhook-id", - Usage: "ID of webhook.", - Required: true, - PathParam: "webhook_id", - }, - }, - Action: handleWebhooksDelete, - HideHelpCommand: true, -} - -var webhooksGet = cli.Command{ - Name: "get", - Usage: "**CLI:**", - Suggest: true, - Flags: []cli.Flag{ - &requestflag.Flag[string]{ - Name: "webhook-id", - Usage: "ID of webhook.", - Required: true, - PathParam: "webhook_id", - }, - }, - Action: handleWebhooksGet, - HideHelpCommand: true, -} - -func handleWebhooksCreate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.WebhookNewParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Webhooks.New(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "webhooks create", - Transform: transform, - }) -} - -func handleWebhooksUpdate(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("webhook-id") && len(unusedArgs) > 0 { - cmd.Set("webhook-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - ApplicationJSON, - false, - ) - if err != nil { - return err - } - - params := agentmail.WebhookUpdateParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Webhooks.Update( - ctx, - cmd.Value("webhook-id").(string), - params, - options..., - ) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "webhooks update", - Transform: transform, - }) -} - -func handleWebhooksList(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - params := agentmail.WebhookListParams{} - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Webhooks.List(ctx, params, options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "webhooks list", - Transform: transform, - }) -} - -func handleWebhooksDelete(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("webhook-id") && len(unusedArgs) > 0 { - cmd.Set("webhook-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - return client.Webhooks.Delete(ctx, cmd.Value("webhook-id").(string), options...) -} - -func handleWebhooksGet(ctx context.Context, cmd *cli.Command) error { - client := agentmail.NewClient(getDefaultRequestOptions(cmd)...) - unusedArgs := cmd.Args().Slice() - if !cmd.IsSet("webhook-id") && len(unusedArgs) > 0 { - cmd.Set("webhook-id", unusedArgs[0]) - unusedArgs = unusedArgs[1:] - } - if len(unusedArgs) > 0 { - return fmt.Errorf("Unexpected extra arguments: %v", unusedArgs) - } - - options, err := flagOptions( - cmd, - apiquery.NestedQueryFormatBrackets, - apiquery.ArrayQueryFormatComma, - EmptyBody, - false, - ) - if err != nil { - return err - } - - var res []byte - options = append(options, option.WithResponseBodyInto(&res)) - _, err = client.Webhooks.Get(ctx, cmd.Value("webhook-id").(string), options...) - if err != nil { - return err - } - - obj := gjson.ParseBytes(res) - format := cmd.Root().String("format") - explicitFormat := cmd.Root().IsSet("format") - transform := cmd.Root().String("transform") - return ShowJSON(obj, ShowJSONOpts{ - ExplicitFormat: explicitFormat, - Format: format, - RawOutput: cmd.Root().Bool("raw-output"), - Title: "webhooks get", - Transform: transform, - }) -} diff --git a/pkg/cmd/webhook_test.go b/pkg/cmd/webhook_test.go deleted file mode 100644 index 90f7279..0000000 --- a/pkg/cmd/webhook_test.go +++ /dev/null @@ -1,110 +0,0 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - -package cmd - -import ( - "testing" - - "github.com/agentmail-to/agentmail-cli/internal/mocktest" -) - -func TestWebhooksCreate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "webhooks", "create", - "--event-type", "message.received", - "--url", "url", - "--client-id", "client_id", - "--pod-id", "[string]", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "event_types:\n" + - " - message.received\n" + - "url: url\n" + - "client_id: client_id\n" + - "pod_ids:\n" + - " - string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "webhooks", "create", - ) - }) -} - -func TestWebhooksUpdate(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "webhooks", "update", - "--webhook-id", "webhook_id", - "--add-pod-id", "[string]", - "--event-type", "[message.received]", - "--remove-pod-id", "[string]", - ) - }) - - t.Run("piping data", func(t *testing.T) { - // Test piping YAML data over stdin - pipeData := []byte("" + - "add_pod_ids:\n" + - " - string\n" + - "event_types:\n" + - " - message.received\n" + - "remove_pod_ids:\n" + - " - string\n") - mocktest.TestRunMockTestWithPipeAndFlags( - t, pipeData, - "--api-key", "string", - "webhooks", "update", - "--webhook-id", "webhook_id", - ) - }) -} - -func TestWebhooksList(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "webhooks", "list", - "--ascending=true", - "--limit", "0", - "--page-token", "page_token", - ) - }) -} - -func TestWebhooksDelete(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "webhooks", "delete", - "--webhook-id", "webhook_id", - ) - }) -} - -func TestWebhooksGet(t *testing.T) { - t.Skip("Mock server tests are disabled") - t.Run("regular flags", func(t *testing.T) { - mocktest.TestRunMockTestWithFlags( - t, - "--api-key", "string", - "webhooks", "get", - "--webhook-id", "webhook_id", - ) - }) -} diff --git a/reference.md b/reference.md new file mode 100644 index 0000000..0c969c1 --- /dev/null +++ b/reference.md @@ -0,0 +1,2205 @@ +# AgentMail CLI Reference + +Full command reference for `agentmail`. + +## Commands + +- [`agentmail agent`](#agentmail-agent) +- [`agentmail api-keys`](#agentmail-api-keys) +- [`agentmail auth`](#agentmail-auth) +- [`agentmail domains`](#agentmail-domains) +- [`agentmail drafts`](#agentmail-drafts) +- [`agentmail inboxes`](#agentmail-inboxes) +- [`agentmail inboxes api-keys`](#agentmail-inboxes-api-keys) +- [`agentmail inboxes drafts`](#agentmail-inboxes-drafts) +- [`agentmail inboxes events`](#agentmail-inboxes-events) +- [`agentmail inboxes lists`](#agentmail-inboxes-lists) +- [`agentmail inboxes messages`](#agentmail-inboxes-messages) +- [`agentmail inboxes metrics`](#agentmail-inboxes-metrics) +- [`agentmail inboxes threads`](#agentmail-inboxes-threads) +- [`agentmail inboxes webhooks`](#agentmail-inboxes-webhooks) +- [`agentmail lists`](#agentmail-lists) +- [`agentmail metrics`](#agentmail-metrics) +- [`agentmail organizations`](#agentmail-organizations) +- [`agentmail pods`](#agentmail-pods) +- [`agentmail pods api-keys`](#agentmail-pods-api-keys) +- [`agentmail pods domains`](#agentmail-pods-domains) +- [`agentmail pods drafts`](#agentmail-pods-drafts) +- [`agentmail pods inboxes`](#agentmail-pods-inboxes) +- [`agentmail pods lists`](#agentmail-pods-lists) +- [`agentmail pods metrics`](#agentmail-pods-metrics) +- [`agentmail pods threads`](#agentmail-pods-threads) +- [`agentmail pods webhooks`](#agentmail-pods-webhooks) +- [`agentmail threads`](#agentmail-threads) +- [`agentmail webhooks`](#agentmail-webhooks) + +--- + +### `agentmail agent` + +#### `agentmail agent sign-up` + +Create a new agent organization with an inbox and API key. This endpoint is for signing up for the first time. If you've already signed up, you're all set — just use your existing API key. + +A 6-digit OTP is sent to the human's email for verification. + +This endpoint is idempotent. Calling it again with the same `human_email` will rotate the API key and resend the OTP if expired. + +The returned API key has limited permissions until the organization is verified via the verify endpoint. + +**CLI:** +```bash +agentmail agent sign-up --human-email user@example.com --username my-agent +``` + +`POST /v0/agent/sign-up` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail agent verify` + +Verify an agent organization using the 6-digit OTP sent to the human's email during sign-up. + +On success, the organization is upgraded from `agent_unverified` to `agent_verified`, the send allowlist is removed, and free plan entitlements are applied. + +The OTP expires after 24 hours and allows a maximum of 10 attempts. If you run into any difficulties receiving the OTP code, you can also create an account on [console.agentmail.to](https://console.agentmail.to) using the human email address you provided to verify your account. + +**CLI:** +```bash +agentmail agent verify --otp-code 123456 +``` + +`POST /v0/agent/verify` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail api-keys` + +#### `agentmail api-keys create` + +**CLI:** +```bash +agentmail api-keys create --name "My Key" +``` + +`POST /v0/api-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail api-keys create-public-key` + +Register a public P-256 JWK using an existing AgentMail bearer API key +with `api_key_create`. Re-registering the same JWK creates a new +credential ID; it does not replace or recover an earlier credential. +The private key must never be sent to AgentMail. + +`POST /v0/api-keys/public-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail api-keys delete` + +**CLI:** +```bash +agentmail api-keys delete --api-key-id +``` + +`DELETE /v0/api-keys/{api_key_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--api-key-id` | `ApiKeyId` | Yes | | + +#### `agentmail api-keys list` + +**CLI:** +```bash +agentmail api-keys list +``` + +`GET /v0/api-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail api-keys list-public-keys` + +List only public-key credentials visible to the bearer caller's scope. +Bearer credentials are never returned, even though both credential types +share storage and pagination indexes. Requires `api_key_read`. + +`GET /v0/api-keys/public-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail api-keys revoke-all-agent-id-sign-in-keys` + +Invalidate every current public-key credential in the caller's +organization by advancing its AgentID key generation. The caller must be +organization-scoped and either have `api_key_delete` or, for a verified +self-serve agent organization, use an unrestricted unmanaged bearer +credential. No request body is accepted. + +`Idempotency-Key` is required and must be a UUID. Reusing the same UUID +returns the original permanent receipt without advancing the generation +again. A new UUID performs a new generation advance. + +`POST /v0/api-keys/public-keys/agentid-sign-in/revoke-all` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--idempotency-key` | `string (uuid)` | Yes | Required UUID identifying this revoke-all operation permanently. | + +#### `agentmail api-keys revoke-public-key` + +Permanently revoke one public-key credential. This hard-deletes the +credential; repeating the request returns not found. Requires +`api_key_delete`. + +`DELETE /v0/api-keys/public-keys/{api_key_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--api-key-id` | `string (uuid)` | Yes | Public-key credential ID returned by registration. | + +#### `agentmail api-keys update-public-key-name` + +Rename the credential. All security-relevant fields are immutable. +Requires `api_key_update`. + +`PATCH /v0/api-keys/public-keys/{api_key_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--api-key-id` | `string (uuid)` | Yes | Public-key credential ID returned by registration. | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail auth` + +#### `agentmail auth me` + +Returns the identity and scope of the authenticated credential. Useful when a client holds a pod-scoped or inbox-scoped API key and needs to discover the parent organization, pod, or inbox without prior knowledge. + +**CLI:** +```bash +agentmail auth me +``` + +`GET /v0/auth/me` + +--- + +### `agentmail domains` + +#### `agentmail domains create` + +**CLI:** +```bash +agentmail domains create --domain example.com +``` + +`POST /v0/domains` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail domains delete` + +**CLI:** +```bash +agentmail domains delete --domain-id +``` + +`DELETE /v0/domains/{domain_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | `DomainId` | Yes | | + +#### `agentmail domains get` + +**CLI:** +```bash +agentmail domains get --domain-id +``` + +`GET /v0/domains/{domain_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | `DomainId` | Yes | | + +#### `agentmail domains get-zone-file` + +**CLI:** +```bash +agentmail domains get-zone-file --domain-id +``` + +`GET /v0/domains/{domain_id}/zone-file` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | `DomainId` | Yes | | + +#### `agentmail domains list` + +**CLI:** +```bash +agentmail domains list +``` + +`GET /v0/domains` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail domains update` + +**CLI:** +```bash +agentmail domains update --domain-id +``` + +`PATCH /v0/domains/{domain_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | `DomainId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail domains verify` + +**CLI:** +```bash +agentmail domains verify --domain-id +``` + +`POST /v0/domains/{domain_id}/verify` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--domain-id` | `DomainId` | Yes | | + +--- + +### `agentmail drafts` + +#### `agentmail drafts get` + +**CLI:** +```bash +agentmail drafts get --draft-id +``` + +`GET /v0/drafts/{draft_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--draft-id` | `DraftId` | Yes | | + +#### `agentmail drafts get-attachment` + +**CLI:** +```bash +agentmail drafts get-attachment --draft-id --attachment-id +``` + +`GET /v0/drafts/{draft_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--draft-id` | `DraftId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail drafts list` + +**CLI:** +```bash +agentmail drafts list +``` + +`GET /v0/drafts` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | + +--- + +### `agentmail inboxes` + +#### `agentmail inboxes create` + +**CLI:** +```bash +agentmail inboxes create --display-name "My Agent" --username myagent --domain agentmail.to +``` + +`POST /v0/inboxes` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | No | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes delete` + +**CLI:** +```bash +agentmail inboxes delete --inbox-id +``` + +`DELETE /v0/inboxes/{inbox_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | + +#### `agentmail inboxes get` + +**CLI:** +```bash +agentmail inboxes get --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | + +#### `agentmail inboxes list` + +**CLI:** +```bash +agentmail inboxes list +``` + +`GET /v0/inboxes` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail inboxes update` + +**CLI:** +```bash +agentmail inboxes update --inbox-id --display-name "Updated Name" +``` + +`PATCH /v0/inboxes/{inbox_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail inboxes api-keys` + +#### `agentmail inboxes api-keys create` + +**CLI:** +```bash +agentmail inboxes api-keys create --inbox-id --name "My Key" +``` + +`POST /v0/inboxes/{inbox_id}/api-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes api-keys delete` + +**CLI:** +```bash +agentmail inboxes api-keys delete --inbox-id --api-key-id +``` + +`DELETE /v0/inboxes/{inbox_id}/api-keys/{api_key_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--api-key-id` | `ApiKeyId` | Yes | | + +#### `agentmail inboxes api-keys list` + +**CLI:** +```bash +agentmail inboxes api-keys list --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/api-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | + +--- + +### `agentmail inboxes drafts` + +#### `agentmail inboxes drafts create` + +Create a draft. Supply `in_reply_to` to create a reply draft (with +`reply_all` to address the whole thread), whose recipients, subject, and +threading are derived from the referenced message, or `forward_of` to +create a forward draft, which derives the subject, threading, and +forwarded content from the source but keeps recipients caller-supplied. + +**CLI:** +```bash +agentmail inboxes drafts create --inbox-id --to recipient@example.com --subject "Draft subject" --text "Draft body" +``` + +`POST /v0/inboxes/{inbox_id}/drafts` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes drafts delete` + +**CLI:** +```bash +agentmail inboxes drafts delete --inbox-id --draft-id +``` + +`DELETE /v0/inboxes/{inbox_id}/drafts/{draft_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | + +#### `agentmail inboxes drafts get` + +**CLI:** +```bash +agentmail inboxes drafts get --inbox-id --draft-id +``` + +`GET /v0/inboxes/{inbox_id}/drafts/{draft_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | + +#### `agentmail inboxes drafts get-attachment` + +**CLI:** +```bash +agentmail inboxes drafts get-attachment --inbox-id --draft-id --attachment-id +``` + +`GET /v0/inboxes/{inbox_id}/drafts/{draft_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail inboxes drafts list` + +**CLI:** +```bash +agentmail inboxes drafts list --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/drafts` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail inboxes drafts send` + +**CLI:** +```bash +agentmail inboxes drafts send --inbox-id --draft-id +``` + +`POST /v0/inboxes/{inbox_id}/drafts/{draft_id}/send` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | +| `--idempotency-key` | `string` | No | Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes. | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes drafts update` + +Edit fields on an existing draft. Passing `null` clears a field (or `[]` +for a recipient field); `send_at: null` un-schedules a scheduled draft. +A draft that is already being sent cannot be edited. + +**CLI:** +```bash +agentmail inboxes drafts update --inbox-id --draft-id --subject "Updated subject" +``` + +`PATCH /v0/inboxes/{inbox_id}/drafts/{draft_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail inboxes events` + +#### `agentmail inboxes events list` + +List label change events for an inbox. Returns events in reverse chronological order by default. Use for IMAP UID projection or audit logging. + +**CLI:** +```bash +agentmail inboxes events list --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/events` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +--- + +### `agentmail inboxes lists` + +#### `agentmail inboxes lists create` + +**CLI:** +```bash +agentmail inboxes lists create --inbox-id --direction --type --entry user@example.com +``` + +`POST /v0/inboxes/{inbox_id}/lists/{direction}/{type}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes lists delete` + +**CLI:** +```bash +agentmail inboxes lists delete --inbox-id --direction --type --entry +``` + +`DELETE /v0/inboxes/{inbox_id}/lists/{direction}/{type}/{entry}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--entry` | `string` | Yes | Email address or domain. | + +#### `agentmail inboxes lists get` + +**CLI:** +```bash +agentmail inboxes lists get --inbox-id --direction --type --entry +``` + +`GET /v0/inboxes/{inbox_id}/lists/{direction}/{type}/{entry}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--entry` | `string` | Yes | Email address or domain. | + +#### `agentmail inboxes lists list` + +**CLI:** +```bash +agentmail inboxes lists list --inbox-id --direction --type +``` + +`GET /v0/inboxes/{inbox_id}/lists/{direction}/{type}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | + +--- + +### `agentmail inboxes messages` + +#### `agentmail inboxes messages batch-get` + +Fetch metadata for up to 500 messages in one request. Missing or +restricted IDs are silently omitted; compare `count` against `limit` +to detect misses. + +**CLI:** +```bash +agentmail inboxes messages batch-get --inbox-id --message-ids --message-ids +``` + +`POST /v0/inboxes/{inbox_id}/messages/batch-get` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes messages batch-update` + +Apply one label change to up to 50 messages in a single request. The +same add_labels and remove_labels apply to every message id, and at +least one of them must be provided. The update is atomic: either all +resolved messages are updated or none are. Missing or restricted ids +are silently excluded; compare `count` against `limit` to detect +exclusions. + +**CLI:** +```bash +agentmail inboxes messages batch-update --inbox-id --message-ids --message-ids --add-labels read --remove-labels unread +``` + +`POST /v0/inboxes/{inbox_id}/messages/batch-update` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes messages delete` + +Permanently deletes a message. + +**CLI:** +```bash +agentmail inboxes messages delete --inbox-id --message-id +``` + +`DELETE /v0/inboxes/{inbox_id}/messages/{message_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | + +#### `agentmail inboxes messages forward` + +**CLI:** +```bash +agentmail inboxes messages forward --inbox-id --message-id --to recipient@example.com +``` + +`POST /v0/inboxes/{inbox_id}/messages/{message_id}/forward` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | +| `--idempotency-key` | `string` | No | Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes. | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes messages get` + +**CLI:** +```bash +agentmail inboxes messages get --inbox-id --message-id +``` + +`GET /v0/inboxes/{inbox_id}/messages/{message_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | + +#### `agentmail inboxes messages get-attachment` + +**CLI:** +```bash +agentmail inboxes messages get-attachment --inbox-id --message-id --attachment-id +``` + +`GET /v0/inboxes/{inbox_id}/messages/{message_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail inboxes messages get-raw` + +**CLI:** +```bash +agentmail inboxes messages get-raw --inbox-id --message-id +``` + +`GET /v0/inboxes/{inbox_id}/messages/{message_id}/raw` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | + +#### `agentmail inboxes messages list` + +Lists messages in the inbox, most recent first. Pass `from`, `to`, or +`subject` to filter by substring. Filtered requests are served by +search, which caps `limit` at 100. For relevance-ranked full-text +search across sender, recipients, subject, and message body, use +`Search Messages`. + +**CLI:** +```bash +agentmail inboxes messages list --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/messages` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | +| `--include-spam` | `IncludeSpam` | No | | +| `--include-blocked` | `IncludeBlocked` | No | | +| `--include-unauthenticated` | `IncludeUnauthenticated` | No | | +| `--include-trash` | `IncludeTrash` | No | | +| `--from` | `string[]` | No | Filter to messages whose sender contains this value (substring match). Repeatable; all values must match. | +| `--to` | `string[]` | No | Filter to messages whose recipients (to, cc, or bcc) contain this value (substring match). Repeatable; all values must match. | +| `--subject` | `string[]` | No | Filter to messages whose subject contains this value (substring match). Repeatable; all values must match. | + +#### `agentmail inboxes messages reply` + +**CLI:** +```bash +agentmail inboxes messages reply --inbox-id --message-id --text "Reply text" +``` + +`POST /v0/inboxes/{inbox_id}/messages/{message_id}/reply` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | +| `--idempotency-key` | `string` | No | Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes. | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes messages reply-all` + +**CLI:** +```bash +agentmail inboxes messages reply-all --inbox-id --message-id --text "Reply text" +``` + +`POST /v0/inboxes/{inbox_id}/messages/{message_id}/reply-all` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | +| `--idempotency-key` | `string` | No | Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes. | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes messages search` + +Full-text search across messages in the inbox, ranked by relevance. The +query is matched against the sender, recipients, and subject (substring) +and the message body (tokenized full text). Spam, trash, blocked, and +unauthenticated messages are always excluded. `limit` cannot exceed 100. + +`GET /v0/inboxes/{inbox_id}/messages/search` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--q` | `Query` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | + +#### `agentmail inboxes messages send` + +**CLI:** +```bash +agentmail inboxes messages send --inbox-id --to recipient@example.com --subject "Hello" --text "Body" +``` + +`POST /v0/inboxes/{inbox_id}/messages/send` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--idempotency-key` | `string` | No | Unique key that makes a send idempotent. A retry carrying the same key returns the original message instead of sending a second email; reusing a key with a different request returns a 409 conflict. Keys expire 24 hours after the send completes. | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes messages update` + +**CLI:** +```bash +agentmail inboxes messages update --inbox-id --message-id --add-labels read --remove-labels unread +``` + +`PATCH /v0/inboxes/{inbox_id}/messages/{message_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--message-id` | `MessageId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail inboxes metrics` + +#### `agentmail inboxes metrics query-events` + +Counts of email events (sent, delivered, bounced, etc.) over time for +the inbox. Defaults to the last 24 hours; `start` must be within the +last 90 days, and a future `end` is clamped to now. Omit `period` for +individual event counts, or set it to sum counts into buckets of that +many seconds. + +**CLI:** +```bash +agentmail inboxes metrics query-events --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/metrics/events` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--event-types` | `MetricEventTypes` | No | | +| `--start` | `Start` | No | | +| `--end` | `End` | No | | +| `--period` | `Period` | No | | +| `--limit` | `MetricLimit` | No | | +| `--descending` | `Descending` | No | | + +#### `agentmail inboxes metrics query-usage` + +Cumulative usage series for the inbox. Each point is the running total +of the usage type at that timestamp, not the change within the bucket. +Inbox-scoped queries carry `storage_bytes`, `message_count`, and +`thread_count`; requested types that don't apply to the scope are +ignored. Defaults to the last 24 hours; `start` must be within the +last 90 days, and a future `end` is clamped to now. The range divided +by `period` must not exceed 1000 buckets. + +`GET /v0/inboxes/{inbox_id}/metrics/usage` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--usage-types` | `UsageTypes` | No | | +| `--start` | `Start` | No | | +| `--end` | `End` | No | | +| `--period` | `Period` | No | | +| `--limit` | `MetricLimit` | No | | +| `--descending` | `Descending` | No | | + +--- + +### `agentmail inboxes threads` + +#### `agentmail inboxes threads delete` + +Permanently deletes a thread and all of its messages. + +**CLI:** +```bash +agentmail inboxes threads delete --inbox-id --thread-id +``` + +`DELETE /v0/inboxes/{inbox_id}/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | + +#### `agentmail inboxes threads get` + +**CLI:** +```bash +agentmail inboxes threads get --inbox-id --thread-id +``` + +`GET /v0/inboxes/{inbox_id}/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | + +#### `agentmail inboxes threads get-attachment` + +**CLI:** +```bash +agentmail inboxes threads get-attachment --inbox-id --thread-id --attachment-id +``` + +`GET /v0/inboxes/{inbox_id}/threads/{thread_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail inboxes threads list` + +Lists threads in the inbox, most recent first. Pass `senders`, +`recipients`, or `subject` to filter by substring. Filtered requests are +served by search, which caps `limit` at 100. For relevance-ranked +full-text search, use `Search Threads`. + +**CLI:** +```bash +agentmail inboxes threads list --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/threads` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | +| `--include-spam` | `IncludeSpam` | No | | +| `--include-blocked` | `IncludeBlocked` | No | | +| `--include-unauthenticated` | `IncludeUnauthenticated` | No | | +| `--include-trash` | `IncludeTrash` | No | | +| `--senders` | `string[]` | No | Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. | +| `--recipients` | `string[]` | No | Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. | +| `--subject` | `string[]` | No | Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. | + +#### `agentmail inboxes threads search` + +Full-text search across threads in the inbox, ranked by relevance. The +query is matched against senders, recipients, and subject (substring) +and the message body (tokenized full text). Spam, trash, blocked, and +unauthenticated threads are always excluded. `limit` cannot exceed 100. + +`GET /v0/inboxes/{inbox_id}/threads/search` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--q` | `Query` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | + +#### `agentmail inboxes threads update` + +Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages. + +`PATCH /v0/inboxes/{inbox_id}/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail inboxes webhooks` + +#### `agentmail inboxes webhooks create` + +Create a webhook scoped to this inbox. + +**CLI:** +```bash +agentmail inboxes webhooks create --inbox-id --url https://example.com/webhook --event-types message.received +``` + +`POST /v0/inboxes/{inbox_id}/webhooks` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes webhooks delete` + +**CLI:** +```bash +agentmail inboxes webhooks delete --inbox-id --webhook-id +``` + +`DELETE /v0/inboxes/{inbox_id}/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail inboxes webhooks get` + +**CLI:** +```bash +agentmail inboxes webhooks get --inbox-id --webhook-id +``` + +`GET /v0/inboxes/{inbox_id}/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail inboxes webhooks get-headers` + +List the names of custom HTTP headers included with deliveries to this inbox-scoped webhook. +Header values are write-only and are never returned. + +`GET /v0/inboxes/{inbox_id}/webhooks/{webhook_id}/headers` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail inboxes webhooks list` + +**CLI:** +```bash +agentmail inboxes webhooks list --inbox-id +``` + +`GET /v0/inboxes/{inbox_id}/webhooks` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail inboxes webhooks update` + +**CLI:** +```bash +agentmail inboxes webhooks update --inbox-id --webhook-id --event-types message.received +``` + +`PATCH /v0/inboxes/{inbox_id}/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail inboxes webhooks update-headers` + +Atomically set, replace, or remove custom HTTP headers included with deliveries to this +inbox-scoped webhook. Header values remain write-only. + +`PATCH /v0/inboxes/{inbox_id}/webhooks/{webhook_id}/headers` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail lists` + +#### `agentmail lists create` + +**CLI:** +```bash +agentmail lists create --direction --type --entry user@example.com +``` + +`POST /v0/lists/{direction}/{type}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail lists delete` + +**CLI:** +```bash +agentmail lists delete --direction --type --entry +``` + +`DELETE /v0/lists/{direction}/{type}/{entry}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--entry` | `string` | Yes | Email address or domain. | + +#### `agentmail lists get` + +**CLI:** +```bash +agentmail lists get --direction --type --entry +``` + +`GET /v0/lists/{direction}/{type}/{entry}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--entry` | `string` | Yes | Email address or domain. | + +#### `agentmail lists list` + +**CLI:** +```bash +agentmail lists list --direction --type +``` + +`GET /v0/lists/{direction}/{type}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | + +--- + +### `agentmail metrics` + +#### `agentmail metrics query-events` + +Counts of email events (sent, delivered, bounced, etc.) over time for +the organization. Defaults to the last 24 hours; `start` must be within +the last 90 days, and a future `end` is clamped to now. Omit `period` +for individual event counts, or set it to sum counts into buckets of +that many seconds. + +**CLI:** +```bash +agentmail metrics query-events +``` + +`GET /v0/metrics/events` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--event-types` | `MetricEventTypes` | No | | +| `--start` | `Start` | No | | +| `--end` | `End` | No | | +| `--period` | `Period` | No | | +| `--limit` | `MetricLimit` | No | | +| `--descending` | `Descending` | No | | + +#### `agentmail metrics query-usage` + +Cumulative usage series for the organization. Each point is the running +total of the usage type at that timestamp, not the change within the +bucket. Defaults to the last 24 hours; `start` must be within the last +90 days, and a future `end` is clamped to now. The range divided by +`period` must not exceed 1000 buckets. + +`GET /v0/metrics/usage` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--usage-types` | `UsageTypes` | No | | +| `--start` | `Start` | No | | +| `--end` | `End` | No | | +| `--period` | `Period` | No | | +| `--limit` | `MetricLimit` | No | | +| `--descending` | `Descending` | No | | + +--- + +### `agentmail organizations` + +#### `agentmail organizations get` + +Returns the organization for the authenticated API key (usage limits, counts, and billing metadata). + +**CLI:** +```bash +agentmail organizations get +``` + +`GET /v0/organizations` + +--- + +### `agentmail pods` + +#### `agentmail pods create` + +**CLI:** +```bash +agentmail pods create --client-id my-pod +``` + +`POST /v0/pods` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods delete` + +**CLI:** +```bash +agentmail pods delete --pod-id +``` + +`DELETE /v0/pods/{pod_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | + +#### `agentmail pods get` + +**CLI:** +```bash +agentmail pods get --pod-id +``` + +`GET /v0/pods/{pod_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | + +#### `agentmail pods list` + +**CLI:** +```bash +agentmail pods list +``` + +`GET /v0/pods` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +--- + +### `agentmail pods api-keys` + +#### `agentmail pods api-keys create` + +**CLI:** +```bash +agentmail pods api-keys create --pod-id --name "My Key" +``` + +`POST /v0/pods/{pod_id}/api-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods api-keys delete` + +**CLI:** +```bash +agentmail pods api-keys delete --pod-id --api-key-id +``` + +`DELETE /v0/pods/{pod_id}/api-keys/{api_key_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--api-key-id` | `ApiKeyId` | Yes | | + +#### `agentmail pods api-keys list` + +**CLI:** +```bash +agentmail pods api-keys list --pod-id +``` + +`GET /v0/pods/{pod_id}/api-keys` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | + +--- + +### `agentmail pods domains` + +#### `agentmail pods domains create` + +**CLI:** +```bash +agentmail pods domains create --pod-id --domain example.com +``` + +`POST /v0/pods/{pod_id}/domains` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods domains delete` + +**CLI:** +```bash +agentmail pods domains delete --pod-id --domain-id +``` + +`DELETE /v0/pods/{pod_id}/domains/{domain_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--domain-id` | `DomainId` | Yes | | + +#### `agentmail pods domains get` + +**CLI:** +```bash +agentmail pods domains get --pod-id --domain-id +``` + +`GET /v0/pods/{pod_id}/domains/{domain_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--domain-id` | `DomainId` | Yes | | + +#### `agentmail pods domains get-zone-file` + +**CLI:** +```bash +agentmail pods domains get-zone-file --pod-id --domain-id +``` + +`GET /v0/pods/{pod_id}/domains/{domain_id}/zone-file` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--domain-id` | `DomainId` | Yes | | + +#### `agentmail pods domains list` + +**CLI:** +```bash +agentmail pods domains list --pod-id +``` + +`GET /v0/pods/{pod_id}/domains` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail pods domains update` + +**CLI:** +```bash +agentmail pods domains update --pod-id --domain-id +``` + +`PATCH /v0/pods/{pod_id}/domains/{domain_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--domain-id` | `DomainId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods domains verify` + +**CLI:** +```bash +agentmail pods domains verify --pod-id --domain-id +``` + +`POST /v0/pods/{pod_id}/domains/{domain_id}/verify` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--domain-id` | `DomainId` | Yes | | + +--- + +### `agentmail pods drafts` + +#### `agentmail pods drafts get` + +**CLI:** +```bash +agentmail pods drafts get --pod-id --draft-id +``` + +`GET /v0/pods/{pod_id}/drafts/{draft_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | + +#### `agentmail pods drafts get-attachment` + +**CLI:** +```bash +agentmail pods drafts get-attachment --pod-id --draft-id --attachment-id +``` + +`GET /v0/pods/{pod_id}/drafts/{draft_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--draft-id` | `DraftId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail pods drafts list` + +**CLI:** +```bash +agentmail pods drafts list --pod-id +``` + +`GET /v0/pods/{pod_id}/drafts` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | + +--- + +### `agentmail pods inboxes` + +#### `agentmail pods inboxes create` + +**CLI:** +```bash +agentmail pods inboxes create --pod-id --username myagent --domain example.com +``` + +`POST /v0/pods/{pod_id}/inboxes` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods inboxes delete` + +**CLI:** +```bash +agentmail pods inboxes delete --pod-id --inbox-id +``` + +`DELETE /v0/pods/{pod_id}/inboxes/{inbox_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--inbox-id` | `inboxesInboxId` | Yes | | + +#### `agentmail pods inboxes get` + +**CLI:** +```bash +agentmail pods inboxes get --pod-id --inbox-id +``` + +`GET /v0/pods/{pod_id}/inboxes/{inbox_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--inbox-id` | `inboxesInboxId` | Yes | | + +#### `agentmail pods inboxes list` + +**CLI:** +```bash +agentmail pods inboxes list --pod-id +``` + +`GET /v0/pods/{pod_id}/inboxes` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail pods inboxes update` + +**CLI:** +```bash +agentmail pods inboxes update --pod-id --inbox-id +``` + +`PATCH /v0/pods/{pod_id}/inboxes/{inbox_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--inbox-id` | `inboxesInboxId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail pods lists` + +#### `agentmail pods lists create` + +**CLI:** +```bash +agentmail pods lists create --pod-id --direction --type --entry user@example.com +``` + +`POST /v0/pods/{pod_id}/lists/{direction}/{type}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods lists delete` + +**CLI:** +```bash +agentmail pods lists delete --pod-id --direction --type --entry +``` + +`DELETE /v0/pods/{pod_id}/lists/{direction}/{type}/{entry}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--entry` | `string` | Yes | Email address or domain. | + +#### `agentmail pods lists get` + +**CLI:** +```bash +agentmail pods lists get --pod-id --direction --type --entry +``` + +`GET /v0/pods/{pod_id}/lists/{direction}/{type}/{entry}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--entry` | `string` | Yes | Email address or domain. | + +#### `agentmail pods lists list` + +**CLI:** +```bash +agentmail pods lists list --pod-id --direction --type +``` + +`GET /v0/pods/{pod_id}/lists/{direction}/{type}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--direction` | `Direction` | Yes | | +| `--type` | `ListType` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | + +--- + +### `agentmail pods metrics` + +#### `agentmail pods metrics query-events` + +Counts of email events (sent, delivered, bounced, etc.) over time for +the pod. Defaults to the last 24 hours; `start` must be within the last +90 days, and a future `end` is clamped to now. Omit `period` for +individual event counts, or set it to sum counts into buckets of that +many seconds. + +**CLI:** +```bash +agentmail pods metrics query-events --pod-id +``` + +`GET /v0/pods/{pod_id}/metrics/events` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--event-types` | `MetricEventTypes` | No | | +| `--start` | `Start` | No | | +| `--end` | `End` | No | | +| `--period` | `Period` | No | | +| `--limit` | `MetricLimit` | No | | +| `--descending` | `Descending` | No | | + +#### `agentmail pods metrics query-usage` + +Cumulative usage series for the pod. Each point is the running total of +the usage type at that timestamp, not the change within the bucket. +Pod-scoped queries carry every usage type except `pod_count`; requested +types that don't apply to the scope are ignored. Defaults to the last +24 hours; `start` must be within the last 90 days, and a future `end` +is clamped to now. The range divided by `period` must not exceed 1000 +buckets. + +`GET /v0/pods/{pod_id}/metrics/usage` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--usage-types` | `UsageTypes` | No | | +| `--start` | `Start` | No | | +| `--end` | `End` | No | | +| `--period` | `Period` | No | | +| `--limit` | `MetricLimit` | No | | +| `--descending` | `Descending` | No | | + +--- + +### `agentmail pods threads` + +#### `agentmail pods threads delete` + +Permanently deletes a thread and all of its messages. + +**CLI:** +```bash +agentmail pods threads delete --pod-id --thread-id +``` + +`DELETE /v0/pods/{pod_id}/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | + +#### `agentmail pods threads get` + +**CLI:** +```bash +agentmail pods threads get --pod-id --thread-id +``` + +`GET /v0/pods/{pod_id}/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | + +#### `agentmail pods threads get-attachment` + +**CLI:** +```bash +agentmail pods threads get-attachment --pod-id --thread-id --attachment-id +``` + +`GET /v0/pods/{pod_id}/threads/{thread_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail pods threads list` + +Lists threads in the pod, most recent first. Pass `senders`, +`recipients`, or `subject` to filter by substring. Filtered requests are +served by search, which caps `limit` at 100. For relevance-ranked +full-text search, use `Search Threads`. + +**CLI:** +```bash +agentmail pods threads list --pod-id +``` + +`GET /v0/pods/{pod_id}/threads` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | +| `--include-spam` | `IncludeSpam` | No | | +| `--include-blocked` | `IncludeBlocked` | No | | +| `--include-unauthenticated` | `IncludeUnauthenticated` | No | | +| `--include-trash` | `IncludeTrash` | No | | +| `--senders` | `string[]` | No | Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. | +| `--recipients` | `string[]` | No | Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. | +| `--subject` | `string[]` | No | Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. | + +#### `agentmail pods threads search` + +Full-text search across threads in the pod, ranked by relevance. The +query is matched against senders, recipients, and subject (substring) +and the message body (tokenized full text). Spam, trash, blocked, and +unauthenticated threads are always excluded. `limit` cannot exceed 100. + +`GET /v0/pods/{pod_id}/threads/search` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--q` | `Query` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | + +#### `agentmail pods threads update` + +Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages. + +`PATCH /v0/pods/{pod_id}/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--thread-id` | `ThreadId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail pods webhooks` + +#### `agentmail pods webhooks create` + +Create a webhook scoped to this pod. + +**CLI:** +```bash +agentmail pods webhooks create --pod-id --url https://example.com/webhook --event-types message.received +``` + +`POST /v0/pods/{pod_id}/webhooks` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods webhooks delete` + +**CLI:** +```bash +agentmail pods webhooks delete --pod-id --webhook-id +``` + +`DELETE /v0/pods/{pod_id}/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail pods webhooks get` + +**CLI:** +```bash +agentmail pods webhooks get --pod-id --webhook-id +``` + +`GET /v0/pods/{pod_id}/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail pods webhooks get-headers` + +List the names of custom HTTP headers included with deliveries to this pod-scoped webhook. +Header values are write-only and are never returned. + +`GET /v0/pods/{pod_id}/webhooks/{webhook_id}/headers` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail pods webhooks list` + +**CLI:** +```bash +agentmail pods webhooks list --pod-id +``` + +`GET /v0/pods/{pod_id}/webhooks` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail pods webhooks update` + +**CLI:** +```bash +agentmail pods webhooks update --pod-id --webhook-id --add-inbox-ids +``` + +`PATCH /v0/pods/{pod_id}/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail pods webhooks update-headers` + +Atomically set, replace, or remove custom HTTP headers included with deliveries to this +pod-scoped webhook. Header values remain write-only. + +`PATCH /v0/pods/{pod_id}/webhooks/{webhook_id}/headers` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--pod-id` | `podsPodId` | Yes | | +| `--webhook-id` | `webhooksWebhookId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail threads` + +#### `agentmail threads delete` + +Permanently deletes a thread and all of its messages. + +**CLI:** +```bash +agentmail threads delete --thread-id +``` + +`DELETE /v0/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--thread-id` | `ThreadId` | Yes | | + +#### `agentmail threads get` + +**CLI:** +```bash +agentmail threads get --thread-id +``` + +`GET /v0/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--thread-id` | `ThreadId` | Yes | | + +#### `agentmail threads get-attachment` + +**CLI:** +```bash +agentmail threads get-attachment --thread-id --attachment-id +``` + +`GET /v0/threads/{thread_id}/attachments/{attachment_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--thread-id` | `ThreadId` | Yes | | +| `--attachment-id` | `AttachmentId` | Yes | | + +#### `agentmail threads list` + +Lists threads, most recent first. Pass `senders`, `recipients`, or +`subject` to filter by substring. Filtered requests are served by +search, which caps `limit` at 100. For relevance-ranked full-text +search across senders, recipients, subject, and message body, use +`Search Threads`. + +**CLI:** +```bash +agentmail threads list +``` + +`GET /v0/threads` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--labels` | `Labels` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | +| `--ascending` | `Ascending` | No | | +| `--include-spam` | `IncludeSpam` | No | | +| `--include-blocked` | `IncludeBlocked` | No | | +| `--include-unauthenticated` | `IncludeUnauthenticated` | No | | +| `--include-trash` | `IncludeTrash` | No | | +| `--senders` | `string[]` | No | Filter to threads whose senders contain this value (substring match). Repeatable; all values must match. | +| `--recipients` | `string[]` | No | Filter to threads whose recipients contain this value (substring match). Repeatable; all values must match. | +| `--subject` | `string[]` | No | Filter to threads whose subject contains this value (substring match). Repeatable; all values must match. | + +#### `agentmail threads search` + +Full-text search across threads in the organization, ranked by +relevance. The query is matched against senders, recipients, and +subject (substring) and the message body (tokenized full text). Spam, +trash, blocked, and unauthenticated threads are always excluded. +`limit` cannot exceed 100. + +`GET /v0/threads/search` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--q` | `Query` | Yes | | +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--before` | `Before` | No | | +| `--after` | `After` | No | | + +#### `agentmail threads update` + +Updates thread labels. Cannot add or remove system labels (sent, received, bounced, etc.). Rejects requests with a `422` for threads with 100 or more messages. + +`PATCH /v0/threads/{thread_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--thread-id` | `ThreadId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +### `agentmail webhooks` + +#### `agentmail webhooks create` + +**CLI:** +```bash +agentmail webhooks create --url https://example.com/webhook --event-types message.received +``` + +`POST /v0/webhooks` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail webhooks delete` + +**CLI:** +```bash +agentmail webhooks delete --webhook-id +``` + +`DELETE /v0/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail webhooks get` + +**CLI:** +```bash +agentmail webhooks get --webhook-id +``` + +`GET /v0/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail webhooks get-headers` + +List the names of custom HTTP headers included with deliveries to this webhook. Header values are +write-only and are never returned. + +`GET /v0/webhooks/{webhook_id}/headers` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--webhook-id` | `webhooksWebhookId` | Yes | | + +#### `agentmail webhooks list` + +**CLI:** +```bash +agentmail webhooks list +``` + +`GET /v0/webhooks` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--limit` | `Limit` | No | | +| `--page-token` | `PageToken` | No | | +| `--ascending` | `Ascending` | No | | + +#### `agentmail webhooks update` + +Update inbox or pod subscriptions, or replace the webhook's `event_types` in full when you pass a +non-empty `event_types` array (see request field docs). Inbox and pod changes use add/remove lists. + +**CLI:** +```bash +agentmail webhooks update --webhook-id --add-inbox-ids +``` + +`PATCH /v0/webhooks/{webhook_id}` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--webhook-id` | `webhooksWebhookId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +#### `agentmail webhooks update-headers` + +Atomically set, replace, or remove custom HTTP headers included with deliveries to this webhook. +Header values remain write-only. + +`PATCH /v0/webhooks/{webhook_id}/headers` + +| Flag | Type | Required | Description | +|------|------|----------|-------------| +| `--webhook-id` | `webhooksWebhookId` | Yes | | +| `--json` | `JSON` | Yes | Request body as JSON (or use individual body-field flags) | + +--- + +## Global flags + +These flags are available on every command: + +| Flag | Description | +|------|-------------| +| `--dry-run` | Print the HTTP request without sending it | +| `--json ` | Supply the request body as JSON (or `-` for stdin) | +| `--params ` | Merge extra parameters as JSON | +| `--format ` | Output format (default: `json`) | +| `--output ` | Write binary responses to a file | +| `--base-url ` | Override the API base URL | +| `--page-all` | Auto-paginate and stream all results | +| `--page-limit ` | Max pages to fetch (default: `10`) | +| `-q, --quiet` | Suppress stdout on success | +| `-h, --help` | Print help | +| `-V, --version` | Print version | + diff --git a/release-please-config.json b/release-please-config.json deleted file mode 100644 index 4bd21c5..0000000 --- a/release-please-config.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "packages": { - ".": {} - }, - "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", - "include-v-in-tag": true, - "include-component-in-tag": false, - "versioning": "prerelease", - "prerelease": true, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "pull-request-header": "Automated Release PR", - "pull-request-title-pattern": "release: ${version}", - "changelog-sections": [ - { - "type": "feat", - "section": "Features" - }, - { - "type": "fix", - "section": "Bug Fixes" - }, - { - "type": "perf", - "section": "Performance Improvements" - }, - { - "type": "revert", - "section": "Reverts" - }, - { - "type": "chore", - "section": "Chores" - }, - { - "type": "docs", - "section": "Documentation" - }, - { - "type": "style", - "section": "Styles" - }, - { - "type": "refactor", - "section": "Refactors" - }, - { - "type": "test", - "section": "Tests", - "hidden": true - }, - { - "type": "build", - "section": "Build System" - }, - { - "type": "ci", - "section": "Continuous Integration", - "hidden": true - } - ], - "release-type": "simple", - "extra-files": [ - "pkg/cmd/version.go", - "README.md" - ] -} \ No newline at end of file diff --git a/scripts/bootstrap b/scripts/bootstrap deleted file mode 100755 index bbc786d..0000000 --- a/scripts/bootstrap +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then - brew bundle check >/dev/null 2>&1 || { - echo -n "==> Install Homebrew dependencies? (y/N): " - read -r response - case "$response" in - [yY][eE][sS]|[yY]) - brew bundle - ;; - *) - ;; - esac - echo - } -fi -echo "==> Installing Go dependencies…" -go mod tidy -e || true diff --git a/scripts/build b/scripts/build deleted file mode 100755 index 9b52c4a..0000000 --- a/scripts/build +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/agentmail-to/agentmail-go,github.com/stainless-sdks/agentmail-go" - -echo "==> Building agentmail" -go build ./cmd/agentmail diff --git a/scripts/format b/scripts/format deleted file mode 100755 index db2a3fa..0000000 --- a/scripts/format +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Running gofmt -s -w" -gofmt -s -w . diff --git a/scripts/link b/scripts/link deleted file mode 100755 index eb04683..0000000 --- a/scripts/link +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/agentmail-to/agentmail-go,github.com/stainless-sdks/agentmail-go" - -REPLACEMENT="${1:-"../agentmail-go"}" -echo "==> Replacing Go SDK with $REPLACEMENT" -if [[ -d "$REPLACEMENT" ]] || go list -m "$REPLACEMENT" >/dev/null; then - go mod edit -replace github.com/agentmail-to/agentmail-go="$REPLACEMENT" - go mod tidy -e -else - echo "Skipping Go SDK replacement (branch may not exist on Go SDK)" -fi diff --git a/scripts/lint b/scripts/lint deleted file mode 100755 index 0854d39..0000000 --- a/scripts/lint +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/agentmail-to/agentmail-go,github.com/stainless-sdks/agentmail-go" - -echo "==> Running Go build" -go build ./... diff --git a/scripts/run b/scripts/run deleted file mode 100755 index 3df1082..0000000 --- a/scripts/run +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/agentmail-to/agentmail-go,github.com/stainless-sdks/agentmail-go" - -go run ./cmd/agentmail "$@" diff --git a/scripts/test b/scripts/test deleted file mode 100755 index 6e608db..0000000 --- a/scripts/test +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -# Mark the necessary Go modules as private to avoid Go's proxy -export GOPRIVATE="${GOPRIVATE:+$GOPRIVATE,}github.com/agentmail-to/agentmail-go,github.com/stainless-sdks/agentmail-go" - - - -echo "==> Running tests" -go test ./... "$@" - -echo "==> Checking tests on Windows" -GOARCH=amd64 GOOS=windows go test -c ./... "$@" -# `go test -c` produces a bunch of .exe files; make sure to clean those up -find . -name "*.test.exe" -exec rm {} \; diff --git a/scripts/unlink b/scripts/unlink deleted file mode 100755 index 279b7f7..0000000 --- a/scripts/unlink +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -echo "==> Unlinking with local directory" -go mod edit -dropreplace github.com/agentmail-to/agentmail-go diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh deleted file mode 100755 index e3bc57b..0000000 --- a/scripts/utils/upload-artifact.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -set -exuo pipefail - -BINARY_NAME="agentmail" -DIST_DIR="dist" -FILENAME="dist.zip" - -files=() -while IFS= read -r -d '' file; do - files+=("$file") -done < <(find "$DIST_DIR" -type f \( \ - -path "*amd64*/$BINARY_NAME" -o \ - -path "*arm64*/$BINARY_NAME" -o \ - -path "*amd64*/${BINARY_NAME}.exe" -o \ - -path "*arm64*/${BINARY_NAME}.exe" \ - \) -print0) - -if [[ ${#files[@]} -eq 0 ]]; then - echo -e "\033[31mNo binaries found for packaging.\033[0m" - exit 1 -fi - -rm -f "${DIST_DIR}/${FILENAME}" - -while IFS= read -r -d '' dir; do - printf "Remove the quarantine attribute before running the executable:\n\nxattr -d com.apple.quarantine %s\n" \ - "$BINARY_NAME" >"${dir}/README.txt" - files+=("${dir}/README.txt") -done < <(find "$DIST_DIR" -type d -path '*macos*' -print0) - -relative_files=() -for file in "${files[@]}"; do - relative_files+=("${file#"${DIST_DIR}"/}") -done - -(cd "$DIST_DIR" && zip -r "$FILENAME" "${relative_files[@]}") - -RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ - -H "Authorization: Bearer $AUTH" \ - -H "Content-Type: application/json") - -SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') - -if [[ "$SIGNED_URL" == "null" ]]; then - echo -e "\033[31mFailed to get signed URL.\033[0m" - exit 1 -fi - -UPLOAD_RESPONSE=$(curl -v -X PUT \ - -H "Content-Type: application/zip" \ - --data-binary "@${DIST_DIR}/${FILENAME}" "$SIGNED_URL" 2>&1) - -if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then - echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: Download and unzip: 'https://pkg.stainless.com/s/agentmail-cli/$SHA'. On macOS, run 'xattr -d com.apple.quarantine {executable name}'.\033[0m" -else - echo -e "\033[31mFailed to upload artifact.\033[0m" - exit 1 -fi diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..f7df330 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,1853 @@ +//! Root-level `CliApp` that composes one or more [`Binding`]s into a +//! single CLI binary. +//! +//! **Architectural rule:** `CliApp::run()` always runs the full dispatch +//! pipeline. There is no single-binding shortcut. A binary with one +//! binding goes through exactly the same pipeline as a binary with five. +//! +//! The pipeline: +//! 1. Parse argv → `ArgMatches` +//! 2. Resolve operation path → matched `Binding` +//! 3. Call `Binding::dispatch(...)` (fires transport-scope hooks) +//! 4. Run CliApp-scope `transform_response` chain +//! 5. On error from step 3, run CliApp-scope `recover_error` chain +//! 6. Format and write output +//! +//! See [PR #62 review](https://github.com/fern-api/cli-sdk/pull/62#issuecomment-4484622766) +//! for why the single-binding fast path was removed. + +use std::any::Any; + +use serde_json::Value; + +use crate::auth::root_builder::AuthSchemeBuilder; +use crate::auth::SchemeBinding; +use crate::binding::{Binding, DispatchResult}; +use crate::error::{write_error_json, CliError, ErrorDisplayContext}; +use crate::formatter; +use crate::hooks::HookRegistry; +use crate::stability::Stability; + +/// Handler function for CLI-level custom commands. +/// +/// Receives the parsed [`clap::ArgMatches`] for the subcommand and a +/// type-erased binding context. Use [`OpenApiBinding::handler()`] or +/// [`GraphqlBinding::handler()`] to wrap a typed handler function +/// instead of downcasting manually. +/// +/// [`OpenApiBinding::handler()`]: crate::openapi::OpenApiBinding::handler +/// [`GraphqlBinding::handler()`]: crate::graphql::GraphqlBinding::handler +pub type CliCommandHandler = + Box Result<(), CliError> + Send + Sync>; + +/// A CLI-level custom command: parent path, clap command, and handler. +struct CliCommand { + path: Vec, + cmd: clap::Command, + handler: CliCommandHandler, +} + +/// Outcome of the dispatch pipeline — separates success from +/// help/version display so `CliError` is reserved for real errors. +enum PipelineOutcome { + Success, + HelpShown, +} + +// ── Tier 1 deferred operations ────────────────────────────────────── + +/// A declarative modification to be applied to the clap command tree +/// after all bindings have contributed their subtrees. +enum DeferredOp { + Alias { + path: Vec, + alias: String, + }, + Hide { + path: Vec, + }, + Stability { + path: Vec, + stability: Stability, + }, +} + +// ── Root CliApp ───────────────────────────────────────────────────── + +/// Root-level CLI application builder that composes [`Binding`]s. +/// +/// ```rust,ignore +/// use fern_cli_sdk::app::CliApp; +/// use fern_cli_sdk::openapi::OpenApiBinding; +/// +/// fn main() { +/// CliApp::new("my-cli") +/// .title("My CLI") +/// .description("Interact with the My API from the command line.") +/// .binding( +/// OpenApiBinding::new() +/// .spec(include_str!("openapi.yaml")) +/// .auth_scheme_env("bearer", "MY_API_KEY"), +/// ) +/// .run() +/// } +/// ``` +#[must_use] +pub struct CliApp { + name: String, + title: Option, + description: Option, + bindings: Vec>, + hooks: HookRegistry, + deferred_ops: Vec, + cli_commands: Vec, + /// Root-level auth scheme bindings. These are shared across all + /// bindings — each binding's spec references schemes by name and + /// the credential source is looked up from this registry. + auth_bindings: Vec<(String, SchemeBinding)>, + /// Login flows declared for this CLI. Each populates one auth + /// scheme's keyring entry on ` auth login`. See ADR-0007. + login_flows: Vec, + /// Root-level global parameters. Declared once here (like + /// [`auth_bindings`](Self::auth_bindings)) and shared across all + /// bindings — `propagate_root_global_parameters` hands them to each + /// binding via [`Binding::set_root_global_parameters`], which + /// surfaces them as top-level flags and injects them into requests. + global_parameters: Vec, + /// Optional base URL for per-status-code error documentation links. + /// When set, API errors append `/` to stderr. + error_docs_base_url: Option, +} + +impl CliApp { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + title: None, + description: None, + bindings: Vec::new(), + hooks: HookRegistry::new(), + deferred_ops: Vec::new(), + cli_commands: Vec::new(), + auth_bindings: Vec::new(), + login_flows: Vec::new(), + global_parameters: Vec::new(), + error_docs_base_url: None, + } + } + + // ── CLI metadata ──────────────────────────────────────────────── + + /// Set the top-level `--help` title for this CLI. + pub fn title(mut self, t: &str) -> Self { + self.title = Some(t.to_string()); + self + } + + /// Set the top-level `--help` description for this CLI. + pub fn description(mut self, d: &str) -> Self { + self.description = Some(d.to_string()); + self + } + + /// Set the base URL for per-status-code error documentation links. + /// + /// When set, API errors (HTTP status codes) append a docs URL to stderr: + /// ` → /` (e.g. `https://docs.example.com/errors/401`). + pub fn error_docs_base_url(mut self, url: &str) -> Self { + self.error_docs_base_url = Some(url.to_string()); + self + } + + /// Rename the consumer `User-Agent` suffix flag (and its derived env + /// var). By default the CLI exposes `--user-agent-suffix` and + /// `_USER_AGENT_SUFFIX`; passing `"via"` here exposes `--via` and + /// `_VIA` instead. The name is the flag's long form without the + /// leading `--`. Wired from the generator's `userAgentSuffixFlag` + /// custom config; it sets a process-wide value read by the flag + /// registration, help/schema text, and env-var lookup. + pub fn user_agent_suffix_flag(self, name: &str) -> Self { + crate::user_agent::set_suffix_flag(name); + self + } + + // ── Binding registration ──────────────────────────────────────── + + /// Add a binding (protocol adapter) to this CLI. The CLI name is + /// propagated to the binding for HTTP config, logging, and base-URL + /// resolution. + pub fn binding(mut self, mut binding: impl Binding + 'static) -> Self { + binding.set_cli_name(&self.name); + self.bindings.push(Box::new(binding)); + self + } + + // ── Auth registration ──────────────────────────────────────────── + + /// Register an auth scheme at the root CLI level. + /// + /// Auth declared here is shared across all bindings. Each binding's + /// spec references schemes by name (from its `securitySchemes`), and + /// credential resolution comes from this root registry. + /// + /// ```rust,ignore + /// use fern_cli_sdk::app::CliApp; + /// use fern_cli_sdk::auth::{BearerAuth, ApiKeyAuth}; + /// + /// CliApp::new("my-cli") + /// .auth(BearerAuth::new("bearerAuth").env("MY_TOKEN")) + /// .auth(ApiKeyAuth::new("apiKey").env("API_KEY")) + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .run() + /// ``` + pub fn auth(mut self, builder: impl AuthSchemeBuilder) -> Self { + self.auth_bindings.push(builder.into_binding()); + self + } + + // ── Global parameter registration ───────────────────────────────── + + /// Register a global parameter at the root CLI level. + /// + /// Global parameters are declared once here and shared across all + /// bindings — mirroring [`auth`](Self::auth). Before the CLI runs, + /// `propagate_root_global_parameters` hands the full set to each + /// binding via [`Binding::set_root_global_parameters`]; the binding + /// surfaces them as top-level flags and injects the resolved value + /// into outgoing requests at the configured wire location. + /// + /// This is the builder entry point emitted by the TypeScript codegen + /// layer (`detectGlobalParams.ts`) from `ir.globalParameters`. + /// + /// ```rust,ignore + /// use fern_cli_sdk::app::CliApp; + /// use fern_cli_sdk::openapi::OpenApiBinding; + /// use fern_cli_sdk::openapi::discovery::{ + /// GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + /// }; + /// + /// CliApp::new("my-cli") + /// .global_parameter(GlobalParameter { + /// name: "api-version".into(), + /// location: GlobalParameterLocation::Query, + /// target: "api-version".into(), + /// env: Some("MY_API_VERSION".into()), + /// default: None, + /// optional: false, + /// apply: GlobalParameterApplyMode::Auto, + /// parameter_name: None, + /// docs: None, + /// }) + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .run() + /// ``` + pub fn global_parameter( + mut self, + param: crate::openapi::discovery::GlobalParameter, + ) -> Self { + self.global_parameters.push(param); + self + } + + /// Declare a login flow for one of the registered auth schemes. + /// + /// Generated and hand-written CLIs use this to wire ` auth login` + /// to a concrete flow — `DeviceCodeLoginFlow`, `PkceLoginFlow`, or + /// `TokenPasteLoginFlow` from [`crate::auth::login`] / + /// [`crate::auth::oauth2`]. Exactly one flow per scheme (ADR-0007). + /// + /// Token-paste via `--with-token` is universally available regardless + /// of declared flows; this method only matters when a binary wants + /// the OAuth (device-code / PKCE) flow to run automatically. + pub fn login_flow(mut self, flow: impl crate::auth::login::LoginFlow + 'static) -> Self { + let scheme = flow.scheme_name().to_string(); + // If the flow declares a request-time auth provider (OAuth flows + // do, paste does not), register it as a Custom scheme binding so + // the dispatch pipeline uses the OAuth2KeyringProvider on every + // API request — refresh-on-expired included. + if let Some(provider) = flow.build_auth_provider(&self.name) { + // Replace any existing binding for this scheme. The flow's + // provider supersedes plain bearer/header on the same name — + // but emit a warning so a hand-written CLI that wired both + // (e.g. `.auth(BearerAuth::new("X").env("Y")).login_flow(...)`) + // discovers the silent replacement instead of debugging a + // mysteriously-ignored env var. + let prior = self.auth_bindings.len(); + self.auth_bindings.retain(|(n, _)| n != &scheme); + if self.auth_bindings.len() < prior { + tracing::warn!( + scheme = %scheme, + cli = %self.name, + "login_flow() replaced a previously-registered auth binding for scheme `{scheme}` — \ + any .auth() / .auth_scheme_*() / .auth_provider*() call for that scheme is discarded. \ + Move the .login_flow() call before the .auth() call, or drop the .auth() if the login \ + flow's request-time provider is what you want." + ); + } + self.auth_bindings + .push((scheme, crate::auth::builder::SchemeBinding::Custom(provider))); + } + self.login_flows.push(std::sync::Arc::new(flow)); + self + } + + // ── Custom commands ────────────────────────────────────────────── + + /// Register a top-level custom command. + /// + /// Use [`OpenApiBinding::handler()`] or [`GraphqlBinding::handler()`] + /// to wrap a typed handler that receives the concrete binding context: + /// + /// ```rust,ignore + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command(my_command(), OpenApiBinding::handler(my_handler)) + /// .run() + /// ``` + /// + /// **Note:** `transform_response` and `recover_error` hooks do not + /// apply to custom commands. Custom command handlers manage their + /// own output directly. + /// + /// [`OpenApiBinding::handler()`]: crate::openapi::OpenApiBinding::handler + /// [`GraphqlBinding::handler()`]: crate::graphql::GraphqlBinding::handler + pub fn command(mut self, cmd: clap::Command, handler: CliCommandHandler) -> Self { + self.cli_commands.push(CliCommand { + path: Vec::new(), + cmd, + handler, + }); + self + } + + /// Register a top-level custom command with compile-time typed arguments. + /// + /// `A` is a [`clap::Args`] struct (typically `#[derive(clap::Args)]`) + /// whose fields become CLI flags. The handler receives the parsed `A` + /// and the binding context `C` (e.g. [`AppContext`]) directly — no + /// wrapper needed. + /// + /// ```rust,ignore + /// #[derive(clap::Args)] + /// struct AdoptArgs { + /// name: String, + /// #[arg(long)] + /// tag: Option, + /// } + /// + /// fn handle_adopt(args: AdoptArgs, ctx: &AppContext) -> Result<(), CliError> { + /// println!("adopting {}", args.name); + /// Ok(()) + /// } + /// + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command_typed("adopt", "Adopt a pet", handle_adopt) + /// .run() + /// ``` + /// + /// For full [`clap::Command`] customization (long_about, aliases, etc.) + /// use [`command_typed_with`](Self::command_typed_with). + /// + /// **Note:** `transform_response` and `recover_error` hooks do not + /// apply to custom commands. Custom command handlers manage their + /// own output directly. + pub fn command_typed( + self, + name: &str, + about: &str, + handler: fn(A, &C) -> Result<(), CliError>, + ) -> Self + where + A: clap::Args + 'static, + C: 'static, + { + self.command_typed_with( + clap::Command::new(name.to_string()).about(about.to_string()), + handler, + ) + } + + /// Like [`command_typed`](Self::command_typed) but accepts a full + /// [`clap::Command`] for advanced customization. + /// + /// ```rust,ignore + /// app.command_typed_with( + /// clap::Command::new("adopt") + /// .about("Adopt a pet") + /// .long_about("Create and fetch back a pet record."), + /// handle_adopt, + /// ) + /// ``` + pub fn command_typed_with( + mut self, + cmd: clap::Command, + handler: fn(A, &C) -> Result<(), CliError>, + ) -> Self + where + A: clap::Args + 'static, + C: 'static, + { + let augmented = A::augment_args(cmd); + let erased: CliCommandHandler = Box::new(move |matches, ctx| { + let args = A::from_arg_matches(matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + let ctx = ctx.downcast_ref::().ok_or_else(|| { + CliError::Validation("binding context type mismatch".into()) + })?; + handler(args, ctx) + }); + self.cli_commands.push(CliCommand { + path: Vec::new(), + cmd: augmented, + handler: erased, + }); + self + } + + /// Register a custom command under an existing command path. + /// + /// ```rust,ignore + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command_under( + /// &["webhooks"], + /// verify_command(), + /// OpenApiBinding::handler(handle_verify), + /// ) + /// .run() + /// ``` + /// + /// **Note:** `transform_response` and `recover_error` hooks do not + /// apply to custom commands. Custom command handlers manage their + /// own output directly. + pub fn command_under( + mut self, + path: &[&str], + cmd: clap::Command, + handler: CliCommandHandler, + ) -> Self { + self.cli_commands.push(CliCommand { + path: path.iter().map(|s| s.to_string()).collect(), + cmd, + handler, + }); + self + } + + /// Register a typed custom command under an existing command path. + /// + /// Like [`command_typed`](Self::command_typed) but nests the command + /// under `path` in the command tree. + /// + /// ```rust,ignore + /// app.command_under_typed(&["pets"], "find", "Find pets by name", handle_find) + /// ``` + /// + /// For full [`clap::Command`] customization use + /// [`command_under_typed_with`](Self::command_under_typed_with). + pub fn command_under_typed( + self, + path: &[&str], + name: &str, + about: &str, + handler: fn(A, &C) -> Result<(), CliError>, + ) -> Self + where + A: clap::Args + 'static, + C: 'static, + { + self.command_under_typed_with( + path, + clap::Command::new(name.to_string()).about(about.to_string()), + handler, + ) + } + + /// Like [`command_under_typed`](Self::command_under_typed) but + /// accepts a full [`clap::Command`]. + pub fn command_under_typed_with( + mut self, + path: &[&str], + cmd: clap::Command, + handler: fn(A, &C) -> Result<(), CliError>, + ) -> Self + where + A: clap::Args + 'static, + C: 'static, + { + let augmented = A::augment_args(cmd); + let erased: CliCommandHandler = Box::new(move |matches, ctx| { + let args = A::from_arg_matches(matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + let ctx = ctx.downcast_ref::().ok_or_else(|| { + CliError::Validation("binding context type mismatch".into()) + })?; + handler(args, ctx) + }); + self.cli_commands.push(CliCommand { + path: path.iter().map(|s| s.to_string()).collect(), + cmd: augmented, + handler: erased, + }); + self + } + + // ── Tier 1: Declarative ───────────────────────────────────────── + + /// Register an alias for a command at `path`. Invoking the alias + /// produces the same output as the canonical name. + pub fn alias(mut self, path: &[&str], alias: &str) -> Self { + self.deferred_ops.push(DeferredOp::Alias { + path: path.iter().map(|s| s.to_string()).collect(), + alias: alias.to_string(), + }); + self + } + + /// Hide a command from `--help` output. + pub fn hide(mut self, path: &[&str]) -> Self { + self.deferred_ops.push(DeferredOp::Hide { + path: path.iter().map(|s| s.to_string()).collect(), + }); + self + } + + /// Set the stability level for a command. + pub fn stability(mut self, path: &[&str], stability: Stability) -> Self { + self.deferred_ops.push(DeferredOp::Stability { + path: path.iter().map(|s| s.to_string()).collect(), + stability, + }); + self + } + + /// Mark a command as deprecated with a message. + pub fn deprecate(self, path: &[&str], message: &str) -> Self { + self.stability( + path, + Stability::Deprecated { + message: message.to_string(), + replacement: None, + removed_in: None, + }, + ) + } + + // ── Tier 2: Per-command hooks ─────────────────────────────────── + + /// Transform a decoded response value before format/output. + /// Glob path applies across many operations. + pub fn transform_response(mut self, path: &[&str], f: F) -> Self + where + F: Fn(Value, Vec) -> Fut + Send + Sync + 'static, + Fut: std::future::Future> + Send + 'static, + { + self.hooks.add_transform_response( + path, + Box::new(move |v, p| Box::pin(f(v, p))), + ); + self + } + + /// Convert an API error into synthetic success. Returning + /// `Ok(Some(v))` short-circuits with `v` as the response; + /// `Ok(None)` lets the error propagate. + pub fn recover_error(mut self, path: &[&str], f: F) -> Self + where + F: Fn(CliError, Vec) -> Fut + Send + Sync + 'static, + Fut: std::future::Future, CliError>> + Send + 'static, + { + self.hooks.add_recover_error( + path, + Box::new(move |e, p| Box::pin(f(e, p))), + ); + self + } + + // ── Run ───────────────────────────────────────────────────────── + + /// Run the CLI, consuming `self`. Builds the command tree, parses + /// argv, dispatches through the matched binding, applies hooks, + /// and formats output. + pub fn run(self) { + let args: Vec = std::env::args_os().collect(); + self.run_with_args(args) + } + + /// Like [`Self::run`], but takes the argv to parse explicitly + /// instead of pulling from `std::env::args_os()`. Useful when a + /// binary's `main` needs to pre-scan or rewrite argv (e.g. to + /// strip binding-steering flags like `--voice`) before the + /// spec-driven clap tree sees it. Performs the same one-time + /// setup as `run` (sigpipe reset, `.env` load, logging init) and + /// terminates the process with the run's exit code. + pub fn run_with_args(mut self, args: I) + where + I: IntoIterator, + T: Into, + { + crate::reset_sigpipe(); + // Collect now, report after `init_logging` — a warning emitted before the + // subscriber exists is dropped, which would leave a user whose `.env` + // setting was ignored with no explanation at all. + let ignored_dotenv_keys = crate::load_dotenv_filtered(&self.name); + crate::init_logging(&self.name); + crate::warn_ignored_dotenv_keys(&ignored_dotenv_keys); + + self.propagate_root_auth(); + self.propagate_root_global_parameters(); + + let args: Vec = args.into_iter().map(Into::into).collect(); + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let mut out = std::io::stdout().lock(); + let exit = rt.block_on(self.run_inner(args, &mut out)); + drop(out); + std::process::exit(exit); + } + + /// Testable entry point: runs the full pipeline against the given + /// argv and returns the exit code instead of calling + /// `std::process::exit`. Output is written to stdout. + pub fn try_run_from(mut self, args: I) -> i32 + where + I: IntoIterator, + T: Into, + { + self.propagate_root_auth(); + self.propagate_root_global_parameters(); + let args: Vec = args.into_iter().map(Into::into).collect(); + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let mut out = std::io::stdout().lock(); + rt.block_on(self.run_inner(args, &mut out)) + } + + /// Testable entry point that captures output into the provided + /// writer instead of stdout. Returns `(exit_code, bytes_written)`. + /// + /// This is the preferred method for behavior tests — it avoids + /// process-global stdout redirection (`gag`) which is racy under + /// parallel test execution. + pub fn try_run_from_with_output(mut self, args: I, out: &mut W) -> i32 + where + I: IntoIterator, + T: Into, + W: std::io::Write, + { + self.propagate_root_auth(); + self.propagate_root_global_parameters(); + let args: Vec = args.into_iter().map(Into::into).collect(); + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + rt.block_on(self.run_inner(args, out)) + } + + /// Pass root-level auth bindings to each registered binding and + /// validate that specs don't reference unregistered schemes. + /// Must be called before `run_inner` / `dispatch_pipeline`. + fn propagate_root_auth(&mut self) { + // Inject a keyring source into every scheme's credential chain + // before propagating, so ` auth login` populating the keyring + // is visible to the binding-level auth provider without per-binary + // wiring (ADR-0008 § precedence). + crate::auth::login::inject_keyring_sources(&self.name, &mut self.auth_bindings); + + // Wire on-disk token caching into OAuth2 providers that were + // constructed without a cache (i.e. via `root_builder`). + crate::auth::login::inject_oauth2_caches(&self.name, &mut self.auth_bindings); + + if !self.auth_bindings.is_empty() { + for binding in &mut self.bindings { + binding.set_root_auth(&self.auth_bindings); + } + } + } + + /// Pass root-level global parameters to each registered binding. + /// Mirrors [`propagate_root_auth`](Self::propagate_root_auth): + /// parameters are declared once on the root `CliApp` and shared with + /// every binding, which surfaces them as flags and injects them into + /// requests. Must be called before `run_inner` / `dispatch_pipeline`. + fn propagate_root_global_parameters(&mut self) { + if !self.global_parameters.is_empty() { + for binding in &mut self.bindings { + binding.set_root_global_parameters(&self.global_parameters); + } + } + } + + /// Validate auth across all bindings. Hard-errors if any binding's + /// spec references a scheme not registered in auth_bindings. + fn validate_auth(&self) -> Result<(), CliError> { + for binding in &self.bindings { + binding.validate_auth()?; + } + Ok(()) + } + + /// Core async pipeline. Returns exit code (0 = success). + /// + /// **NO SINGLE-BINDING SHORTCUT.** Every execution path goes through + /// the full dispatch pipeline regardless of binding count. + async fn run_inner(&self, args: Vec, out: &mut W) -> i32 { + let str_args: Vec = args.iter() + .filter_map(|a| a.to_str().map(String::from)) + .collect(); + let subcommand_path = crate::cli_args::extract_subcommand_path(&str_args); + let help_hint = if subcommand_path.is_empty() { + format!("{} --help", self.name) + } else { + format!("{} {} --help", self.name, subcommand_path.join(" ")) + }; + let ctx = ErrorDisplayContext { + docs_base_url: self.error_docs_base_url.clone(), + help_hint: Some(help_hint), + }; + match self.dispatch_pipeline(args, out).await { + Ok(PipelineOutcome::Success) => 0, + Ok(PipelineOutcome::HelpShown) => 0, + Err(err) => { + write_error_json(&err, out, Some(&ctx)); + err.exit_code() + } + } + } + + /// The full dispatch pipeline. + async fn dispatch_pipeline( + &self, + args: Vec, + out: &mut W, + ) -> Result { + if self.bindings.is_empty() { + return Err(CliError::Discovery( + "No bindings registered. Call .binding() on CliApp.".to_string(), + )); + } + + // 0. Validate auth bindings — hard error if a binding's spec + // references a scheme not registered at root. + self.validate_auth()?; + + // 0. Convert args to strings for early interception checks. + let str_args: Vec = args.iter() + .filter_map(|a| a.to_str().map(String::from)) + .collect(); + + // 0a. Intercept ` errors` early — before loading specs. + if crate::cli_args::is_errors_subcommand(&str_args) { + crate::error::write_errors_to(&str_args, out); + return Ok(PipelineOutcome::HelpShown); + } + + // 0b. Intercept the `--schema` global flag — the agent-facing + // machine-readable counterpart to `--help`. Done before clap parses + // so paths with required args still emit their spec without tripping + // clap's required-arg validation (mirrors how `--help` is handled). + // + // Each binding's contribution is fetched via `Binding::schema(&path)`. + // Empty path: aggregate across all bindings (operations concatenated, + // sdkVariables unioned, root shape preserved). Non-empty path: first + // binding to own the path wins. A real `Err` from one binding is + // logged and the walk continues — one broken spec cannot mask its + // sibling's surface. + if crate::cli_args::wants_schema(&str_args) { + let path = crate::cli_args::extract_subcommand_path(&str_args); + + if path.is_empty() { + let mut sdk_vars: Vec = Vec::new(); + let mut ops: Vec = Vec::new(); + let mut any_sdk_vars = false; + for binding in &self.bindings { + match binding.schema(&path) { + Ok(Some(serde_json::Value::Array(arr))) => ops.extend(arr), + Ok(Some(serde_json::Value::Object(obj))) => { + if let Some(serde_json::Value::Array(vs)) = + obj.get("sdkVariables") + { + any_sdk_vars = true; + sdk_vars.extend(vs.iter().cloned()); + } + if let Some(serde_json::Value::Array(os)) = + obj.get("operations") + { + ops.extend(os.iter().cloned()); + } + } + // Bindings are contracted to return either a bare + // array of operations OR a `{sdkVariables?, + // operations}` object at empty path. Any other + // shape (scalar, null, object missing both keys) + // is a binding bug — log a warn and skip rather + // than silently injecting a non-operation value + // into the aggregated `operations` array. Mirrors + // the principle the `Err` arm just below establishes: + // one malformed binding must not corrupt the root view. + Ok(Some(other)) => tracing::warn!( + "--schema: binding `{}` returned non-array, non-object value at empty path; skipping: {other}", + binding.name() + ), + Ok(None) => {} + Err(e) => tracing::warn!( + "--schema: binding `{}` errored: {e}", + binding.name() + ), + } + } + // Per ADR-0006: always wrap the empty-path result with + // `globalFlags` (CLI harness affordances available on + // every op). Single-binding CLIs that had a bare-array + // root before now also get the wrapped shape. SDK + // variables surface only when at least one binding + // declared any. + let mut wrapped = serde_json::Map::new(); + wrapped.insert( + "globalFlags".into(), + serde_json::Value::Array(global_flags()), + ); + if any_sdk_vars { + wrapped.insert("sdkVariables".into(), serde_json::Value::Array(sdk_vars)); + } + wrapped.insert("operations".into(), serde_json::Value::Array(ops)); + let output = serde_json::Value::Object(wrapped); + writeln!( + out, + "{}", + serde_json::to_string_pretty(&output).map_err(|e| { + CliError::Validation(format!("Failed to serialize spec: {e}")) + })? + ) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::Success); + } + + for binding in &self.bindings { + match binding.schema(&path) { + Ok(Some(value)) => { + writeln!( + out, + "{}", + serde_json::to_string_pretty(&value).map_err(|e| { + CliError::Validation(format!("Failed to serialize spec: {e}")) + })? + ) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::Success); + } + Ok(None) => {} + Err(e) => tracing::warn!( + "--schema: binding `{}` errored: {e}", + binding.name() + ), + } + } + return Err(CliError::Discovery(format!( + "--schema: no binding contains path `{}`", + path.join(" ") + ))); + } + + // 0c. --spec / --spec-raw: emit embedded OpenAPI spec(s) and exit. + // Root-only (not path-scoped). Multi-binding CLIs emit a YAML + // stream (---delimited, one document per binding). + let wants_spec = crate::cli_args::wants_spec(&str_args); + let wants_spec_raw = crate::cli_args::wants_spec_raw(&str_args); + if wants_spec || wants_spec_raw { + let raw = wants_spec_raw; + let mut documents: Vec = Vec::new(); + for binding in &self.bindings { + match binding.spec_document(raw) { + Ok(Some(yaml)) => documents.push(yaml), + Ok(None) => {} + Err(e) => { + let flag = if raw { "--spec-raw" } else { "--spec" }; + tracing::warn!( + "{flag}: binding `{}` errored: {e}", + binding.name() + ); + } + } + } + + if documents.is_empty() { + let flag = if raw { "--spec-raw" } else { "--spec" }; + return Err(CliError::Discovery(format!( + "{flag}: no binding has an embedded API spec" + ))); + } + + let output = if documents.len() == 1 { + documents.into_iter().next().unwrap() + } else { + let mut output = documents[0].clone(); + for doc in &documents[1..] { + if !output.ends_with('\n') { + output.push('\n'); + } + output.push_str("---\n"); + output.push_str(doc); + } + output + }; + + write!(out, "{output}") + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::Success); + } + + // 1. Build merged command tree from all bindings. + let mut cli = clap::Command::new(self.name.clone()) + .version(env!("CARGO_PKG_VERSION")) + .arg_required_else_help(true) + .subcommand_required(true) + .term_width(200); + if let Some(ref t) = self.title { + cli = cli.about(t.clone()); + } + if let Some(ref d) = self.description { + cli = cli.long_about(d.clone()); + } + cli = cli + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json, table, yaml, csv, raw, jsonl, http. Default: table when stdout is a TTY, json when piped. Override default with _OUTPUT env var. raw emits unmodified server response bytes. jsonl emits one compact JSON value per line (NDJSON); arrays are flattened. http emits the full HTTP response (status line + headers + body) like curl -i (OpenAPI only).") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ) + .arg( + clap::Arg::new("user-agent-suffix") + .long(crate::user_agent::suffix_flag()) + .help(format!( + "Product token appended to the User-Agent (e.g. my-app/1.0), so a tool built on top of this CLI can tag its traffic. Takes precedence over {}.", + crate::user_agent::suffix_env_segment() + )) + .value_name("TOKEN") + .global(true), + ) + // Discoverability only — the `--schema` flag is intercepted before + // clap parses (see step 0b above). Registering it here makes it + // appear in `--help` output so users / agents discover it + // alongside `--help`. + .arg( + clap::Arg::new("schema") + .long("schema") + .help("Print machine-readable JSON schema for this scope (agent-facing counterpart to --help)") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + // Discoverability only — intercepted pre-clap like --schema. + .arg( + clap::Arg::new("spec") + .long("spec") + .help("Print the effective OpenAPI spec (source + overlays + overrides merged) to stdout") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("spec-raw") + .long("spec-raw") + .help("Print the byte-exact embedded source OpenAPI spec(s) to stdout") + .action(clap::ArgAction::SetTrue) + .global(true), + ); + + // Deep-merge every binding's subtree into one placeholder + // command and build the full leaf-path → binding-index map. + // Errors surface (as `CliError::Validation`) if two bindings + // declare the same full leaf path — before any dispatch. + let (merged_subtree, leaf_map, binding_cmds) = + merge_binding_subtrees(&self.bindings)?; + + // Does the spec itself claim a top-level `auth` group? When it + // does, the built-in credential subcommands are folded into it + // (see `graft_builtin_command`) and `auth ` operations owned + // by the spec must reach their binding instead of `dispatch_auth`. + let spec_owns_auth = merged_subtree.find_subcommand("auth").is_some(); + + // Graft the merged subtree's subcommands and binding-level + // global args / about / after_help into the root cli, reusing + // the per-binding commands already built above. + cli = graft_merged_subtree(cli, &binding_cmds, merged_subtree, self.title.is_some()); + + // 1b. Register CLI-level custom commands (may be nested). + for cc in &self.cli_commands { + cli = crate::custom_commands::graft_subcommand(cli, &cc.path, cc.cmd.clone()); + } + + // 1c. Register `completion`, `man`, and `auth` subcommands. + // + // `auth` is always grafted, even on binaries that declare no OAuth + // flow — `auth login --with-token` is the universal credential + // entry point that ships on every Fern CLI (ADR-0007 § always-graft). + cli = graft_builtin_command(cli, crate::completions::completion_command()); + cli = graft_builtin_command(cli, crate::man::man_command()); + cli = graft_builtin_command(cli, crate::auth::login::build_auth_command()); + + // 1d. Apply Tier 1 deferred operations (alias, hide, stability) + // before completion/man generation so aliases appear in tab- + // completion scripts and man pages reflect hidden/stability state. + for op in &self.deferred_ops { + match op { + DeferredOp::Alias { path, alias } => { + cli = apply_alias(cli, path, alias); + } + DeferredOp::Hide { path } => { + cli = apply_hide(cli, path); + } + DeferredOp::Stability { path, stability } => { + cli = apply_stability(cli, path, stability); + } + } + } + + // 1e. Validate hook patterns against the command tree. + self.hooks.validate_patterns(&cli)?; + + // 1f. Intercept `completion` and `man` before clap parses. + if crate::completions::wants_completion(&str_args) { + let raw_shell_arg = + crate::early_intercept::nth_positional(&str_args, 1); + match raw_shell_arg { + Some(s) => match crate::completions::parse_shell(s) { + Some(shell) => { + crate::completions::generate_completion_to(shell, &mut cli, &self.name, out) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::HelpShown); + } + None => { + return Err(CliError::Validation(format!( + "invalid shell: '{s}'. Expected one of: bash, zsh, fish, powershell, elvish" + ))); + } + }, + None => { + if let Some(sub) = cli.find_subcommand_mut("completion") { + let _ = sub.write_help(out); + } + return Ok(PipelineOutcome::HelpShown); + } + } + } + if crate::man::wants_man(&str_args) { + let has_help = str_args.iter().skip(1) + .skip_while(|a| a.as_str() != "man").skip(1) + .any(|a| a == "--help" || a == "-h"); + if has_help { + if let Some(sub) = cli.find_subcommand_mut("man") { + let _ = sub.write_help(out); + } + return Ok(PipelineOutcome::HelpShown); + } + crate::man::generate_man_to(cli, &self.name, out) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(PipelineOutcome::HelpShown); + } + + // 3. Parse argv. + let matches = match cli.try_get_matches_from(&args) { + Ok(m) => m, + Err(e) + if e.kind() == clap::error::ErrorKind::DisplayHelp + || e.kind() + == clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand + || e.kind() == clap::error::ErrorKind::DisplayVersion => + { + let _ = std::io::Write::write_fmt(out, format_args!("{e}")); + let _ = out.flush(); + return Ok(PipelineOutcome::HelpShown); + } + Err(e) => return Err(CliError::Validation(e.to_string())), + }; + + // 4. Resolve which binding owns the matched subcommand. + let (op_path, sub_matches) = resolve_op_path(&matches); + + // 3a. Intercept the always-grafted `auth` subcommand before binding + // resolution — it's framework-owned, not spec-owned, and runs + // synchronously without touching any binding (ADR-0007 § always-graft). + // + // When the spec also declares an `auth` group, only the built-in + // credential subcommands are intercepted; everything else under + // `auth` belongs to the spec and falls through to its binding. + if let Some(("auth", auth_matches)) = matches.subcommand() { + let builtin_sub = matches!( + auth_matches.subcommand_name(), + Some("login" | "logout" | "status") + ); + if builtin_sub || !spec_owns_auth { + crate::auth::login::dispatch_auth( + auth_matches, + &self.name, + &self.auth_bindings, + &self.login_flows, + out, + )?; + return Ok(PipelineOutcome::Success); + } + } + + // 4a. Check CLI-level custom commands first. + for cc in &self.cli_commands { + if let Some(target) = crate::custom_commands::walk_matches_to_custom( + &matches, &cc.path, cc.cmd.get_name(), + ) { + // Collect contexts from ALL bindings so the handler can + // invoke operations from any binding transparently. + let mut ctx: Option> = None; + for b in &self.bindings { + ctx = b.merge_binding_context(&matches, ctx)?; + } + let ctx = ctx.unwrap_or_else(|| Box::new(())); + (cc.handler)(target, ctx.as_ref())?; + return Ok(PipelineOutcome::Success); + } + } + + let binding_idx = resolve_binding_for_leaf(&op_path, &leaf_map) + .ok_or_else(|| { + CliError::Discovery(format!( + "No binding found for command path: {}", + op_path.join(" "), + )) + })?; + + // 5. Dispatch to the binding. NO SHORTCUT — always goes through + // the full pipeline. + let dispatch_result = self.bindings[binding_idx] + .dispatch(&matches, sub_matches, &op_path) + .await; + + // 6. Apply CliApp-scope hooks. + match dispatch_result { + Ok(DispatchResult::Value(value)) => { + // Run transform_response chain. + let transformed = self.hooks.run_transform_response(value, &op_path).await?; + + // Format and write output. + let pipeline = formatter::OutputPipeline::from_matches(&matches, &self.name) + .map_err(|e| CliError::Validation(e.to_string()))?; + pipeline + .emit(out, &transformed, false, true) + .map_err(|e| CliError::Other(e.into()))?; + Ok(PipelineOutcome::Success) + } + Ok(DispatchResult::Handled) => { + // Binding already handled output (dry-run, streaming, etc.). + Ok(PipelineOutcome::Success) + } + Err(err) => { + // Raw sentinel: bytes already on stdout, skip hooks. + if err.is_raw_sentinel() { + return Err(err); + } + // Run recover_error chain. + if self.hooks.has_recover_error() { + match self.hooks.run_recover_error(err, &op_path).await { + Ok(value) => { + let pipeline = formatter::OutputPipeline::from_matches(&matches, &self.name) + .map_err(|e| CliError::Validation(e.to_string()))?; + pipeline + .emit(out, &value, false, true) + .map_err(|e| CliError::Other(e.into()))?; + Ok(PipelineOutcome::Success) + } + Err(e) => Err(e), + } + } else { + Err(err) + } + } + } + } +} + +// ── Command tree helpers ──────────────────────────────────────────── + +/// Walk the `ArgMatches` subcommand chain to extract the operation path +/// and the leaf subcommand's matches. +fn resolve_op_path(matches: &clap::ArgMatches) -> (Vec, &clap::ArgMatches) { + let mut path = Vec::new(); + let mut current = matches; + while let Some((name, sub)) = current.subcommand() { + path.push(name.to_string()); + current = sub; + } + (path, current) +} + +/// Attach `merged_subtree`'s subcommands to `cli`, then dedup-merge each +/// binding's global args / about / after_help into `cli`. +/// +/// `binding_cmds` is the per-binding `clap::Command` vector already built +/// by [`merge_binding_subtrees`] — passed in so we don't call +/// `binding.build_command()` a second time. +/// +/// `title_set` says whether `CliApp::title()` already provided an +/// about line — when true we don't let a binding's about override it. +fn graft_merged_subtree( + mut cli: clap::Command, + binding_cmds: &[clap::Command], + merged_subtree: clap::Command, + title_set: bool, +) -> clap::Command { + // 1. Attach every top-level subcommand from the merged subtree, + // including groups named `completion`, `man`, or `auth`. The + // built-in counterparts are registered AFTER this graft (at step + // 1c in `CliApp::run`) via `graft_builtin_command`, which folds + // them into a spec-owned group of the same name rather than + // letting clap see a duplicate top-level subcommand. + for sub in merged_subtree.get_subcommands().cloned() { + cli = cli.subcommand(sub); + } + + // 2. Walk each binding's command for its global args, about, and + // after_help. Dedup by arg id (skip the root-owned globals to + // avoid clap panic on duplicates). + let mut seen_arg_ids: std::collections::HashSet = [ + "format".to_string(), + "base-url".to_string(), + "user-agent-suffix".to_string(), + "schema".to_string(), + "spec".to_string(), + "spec-raw".to_string(), + "help".to_string(), + "version".to_string(), + ] + .into(); + let mut after_help_sections: Vec = Vec::new(); + + for subcmd in binding_cmds { + for arg in subcmd.get_arguments() { + let id = arg.get_id().as_str(); + if !seen_arg_ids.insert(id.to_string()) { + continue; + } + cli = cli.arg(arg.clone()); + } + // Carry the first binding's about into the root only when + // CliApp::title() didn't already set one. + if !title_set { + if let Some(about) = subcmd.get_about() { + cli = cli.about(about.to_string()); + } + } + if let Some(help) = subcmd.get_after_help() { + after_help_sections.push(help.to_string()); + } + } + if !after_help_sections.is_empty() { + cli = cli.after_help(deduplicate_after_help(&after_help_sections)); + } + cli +} + +/// Graft a framework-owned built-in command (`completion`, `man`, `auth`) +/// into `cli`. +/// +/// When no spec-owned group shares the built-in's name this is a plain +/// `.subcommand(...)`. When one does — e.g. an API with an `auth` resource +/// group — the built-in's subcommands are folded into the spec-owned group +/// so both surfaces stay reachable (` auth login` and ` auth me`). +/// Exact leaf collisions follow the `graft_subcommand` rule: the +/// framework-owned leaf wins. +fn graft_builtin_command(cli: clap::Command, builtin: clap::Command) -> clap::Command { + let name = builtin.get_name().to_string(); + if cli.find_subcommand(&name).is_none() { + return cli.subcommand(builtin); + } + + let builtin_subs: Vec = builtin.get_subcommands().cloned().collect(); + if builtin_subs.is_empty() { + // Leaf built-in (`completion`, `man`) — nothing to fold, and both + // are intercepted pre-clap anyway, so the built-in wins. + return cli.mut_subcommand(name, move |_spec_owned| builtin); + } + cli.mut_subcommand(name, move |spec_owned| { + let mut merged = spec_owned; + for sub in builtin_subs { + merged = crate::custom_commands::graft_subcommand(merged, &[], sub); + } + merged + }) +} + +/// `(binding_idx, full_leaf_path)` for every leaf in a merged command tree. +type LeafMap = Vec<(usize, Vec)>; + +/// Deep-merge the subtrees contributed by all bindings into a single +/// placeholder `clap::Command` and build a leaf-path → binding-index map. +/// +/// **Returns** `(merged_subtree, leaf_map, binding_cmds)` where: +/// - `merged_subtree` is a `clap::Command::new("__merged_subtree__")` whose +/// top-level subcommands are the union of all bindings' top-level +/// subcommands, deep-merged at every level so two bindings can +/// contribute disjoint children under the same group; +/// - `leaf_map` is a [`LeafMap`] of `(binding_idx, full_leaf_path)` entries, +/// one per leaf in the merged tree; +/// - `binding_cmds` is each binding's raw `clap::Command` (in registration +/// order) — handed back so callers can read global args / about / +/// after_help without invoking `binding.build_command()` a second time. +/// +/// **Errors** with `CliError::Validation` when two bindings declare the +/// exact same full leaf path — those would collide unrecoverably at +/// dispatch, so we surface the colliding path(s) up-front. +fn merge_binding_subtrees( + bindings: &[Box], +) -> Result<(clap::Command, LeafMap, Vec), CliError> { + // 1. Build each binding's clap command. + let mut binding_cmds: Vec = Vec::with_capacity(bindings.len()); + for b in bindings { + binding_cmds.push(b.build_command()?); + } + + // 2. Collect every leaf path with its owning binding index. + let mut leaf_map: LeafMap = Vec::new(); + for (idx, cmd) in binding_cmds.iter().enumerate() { + for sub in cmd.get_subcommands() { + let mut path = vec![sub.get_name().to_string()]; + collect_leaves(sub, &mut path, idx, &mut leaf_map); + } + } + + // 3. Detect leaf-path collisions across bindings. Two leaves with + // the same path but different owning binding indexes are a + // collision; identical owner (same idx) is just a duplicate + // within one binding's tree and not our concern here. + // + // Intrinsic top-level commands every binding registers (e.g. + // `generate-skills`) are functionally identical across bindings — + // `merge_command_subtree` produces a single node for them — so + // they're exempt from collision detection. Without this exemption + // every multi-binding CliApp would fail validation on the + // binding-emitted intrinsics alone. See d683bd7 (square panic). + let mut by_path: std::collections::BTreeMap, std::collections::BTreeSet> = + std::collections::BTreeMap::new(); + for (idx, path) in &leaf_map { + if is_intrinsic_top_level_leaf(path) { + continue; + } + by_path.entry(path.clone()).or_default().insert(*idx); + } + let collisions: Vec = by_path + .into_iter() + .filter(|(_, owners)| owners.len() > 1) + .map(|(path, _)| path.join(" ")) + .collect(); + if !collisions.is_empty() { + return Err(CliError::Validation(format!( + "colliding leaf command path(s): {}", + collisions.join(", "), + ))); + } + + // 4. Deep-merge each binding's top-level subcommands into a placeholder. + let mut merged = clap::Command::new("__merged_subtree__"); + for cmd in &binding_cmds { + for sub in cmd.get_subcommands().cloned() { + merged = merge_command_subtree(merged, sub); + } + } + + Ok((merged, leaf_map, binding_cmds)) +} + +/// Find which binding index owns the leaf matched by `op_path`. +/// +/// Returns `None` for empty paths or unknown paths. +fn resolve_binding_for_leaf(op_path: &[String], leaf_map: &LeafMap) -> Option { + if op_path.is_empty() { + return None; + } + leaf_map + .iter() + .find(|(_, leaf)| leaf.as_slice() == op_path) + .map(|(idx, _)| *idx) +} + +/// Names of leaf top-level commands that every binding emits identically +/// (e.g. `generate-skills` from `OpenApiBinding::build_command`). When two +/// bindings each contribute one, the deep-merge produces a single node; +/// the collision detector would otherwise flag them as user-facing +/// conflicts. Exempting them here mirrors the pre-ACP-1.1 intrinsic +/// dedup that d683bd7 introduced for the same square `generate-skills` +/// panic. +fn is_intrinsic_top_level_leaf(path: &[String]) -> bool { + matches!(path, [name] if name == "generate-skills") +} + +/// DFS over a `clap::Command` subtree, pushing `(idx, path.clone())` for +/// every leaf (subcommand with no further subcommands). +fn collect_leaves(cmd: &clap::Command, path: &mut Vec, idx: usize, out: &mut LeafMap) { + let mut has_child = false; + for sub in cmd.get_subcommands() { + has_child = true; + path.push(sub.get_name().to_string()); + collect_leaves(sub, path, idx, out); + path.pop(); + } + if !has_child { + out.push((idx, path.clone())); + } +} + +/// Deep-merge `incoming` into `parent`. If `parent` already has a +/// subcommand with the same name, recurse into it via `mut_subcommand`; +/// otherwise attach `incoming` as a fresh subcommand. Leaf collisions +/// (two subcommands with identical names and no further children) are +/// not detected here — the caller has already vetted leaf paths via +/// `merge_binding_subtrees`. +/// +/// Note: `merge_binding_subtrees` matches on full leaf paths, so it +/// catches the common collision case (two bindings contribute the +/// same operation). It does **not** catch partial-path shadowing — +/// e.g. binding A contributes `users` as a leaf operation while +/// binding B contributes `users → list`. After this merge `users` +/// gains a subcommand, so clap requires a subcommand selection and +/// binding A's leaf operation becomes unreachable. OpenAPI bindings +/// generated from `x-fern-sdk-group-name` structure operations as +/// leaves *under* groups (not at the group level itself), so this +/// case is structurally unreachable for spec-driven bindings; it +/// can only arise from hand-rolled `.command(...)` registrations +/// that mix leaf and group nodes at the same path. +fn merge_command_subtree( + parent: clap::Command, + incoming: clap::Command, +) -> clap::Command { + let incoming_name = incoming.get_name().to_string(); + if parent.find_subcommand(&incoming_name).is_some() { + // Recurse: deep-merge incoming's children into the existing subcommand. + parent.mut_subcommand(incoming_name, move |mut existing| { + for child in incoming.get_subcommands().cloned() { + existing = merge_command_subtree(existing, child); + } + existing + }) + } else { + parent.subcommand(incoming) + } +} + +/// Apply a transform to the command at `path` using clap's +/// `mut_subcommand` to walk the tree. Parent commands are never +/// rebuilt — only the leaf is transformed — so all clap settings on +/// every ancestor are preserved automatically, regardless of what +/// settings clap adds in future versions. +fn modify_at_path( + cmd: clap::Command, + path: &[String], + transform: &dyn Fn(clap::Command) -> clap::Command, +) -> clap::Command { + if path.is_empty() { + return transform(cmd); + } + let head = path[0].clone(); + let rest = path[1..].to_vec(); + cmd.mut_subcommand(head, move |sub| modify_at_path(sub, &rest, transform)) +} + +/// Apply a clap alias to the command at `path`. +fn apply_alias(cli: clap::Command, path: &[String], alias: &str) -> clap::Command { + let alias_owned = alias.to_string(); + modify_at_path(cli, path, &|c| c.visible_alias(alias_owned.clone())) +} + +/// Apply `hide(true)` to the command at `path`. +fn apply_hide(cli: clap::Command, path: &[String]) -> clap::Command { + modify_at_path(cli, path, &|c| c.hide(true)) +} + +/// Apply a stability badge to the command at `path`. +fn apply_stability(cli: clap::Command, path: &[String], stability: &Stability) -> clap::Command { + modify_at_path(cli, path, &|c| { + if let Some(badge) = stability.badge() { + let about = c + .get_about() + .map(|a| format!("{badge} {a}")) + .unwrap_or_else(|| badge.to_string()); + c.about(about) + } else { + c + } + }) +} + +/// Merge multiple `after_help` sections, deduplicating identical blocks +/// while preserving first-seen order. Blocks are delimited by blank +/// lines (`\n\n`). This handles multi-line entries (e.g. auth sections +/// spanning several lines) as atomic units — they're either kept or +/// dropped as a whole, never split. +fn deduplicate_after_help(sections: &[String]) -> String { + let mut seen = std::collections::HashSet::new(); + let mut blocks = Vec::new(); + for section in sections { + // Split each section into blank-line-delimited blocks. + for block in section.split("\n\n") { + let trimmed = block.trim(); + if !trimmed.is_empty() && seen.insert(trimmed.to_string()) { + blocks.push(trimmed.to_string()); + } + } + } + blocks.join("\n\n") +} + +/// Static description of every CLI-level affordance the harness exposes +/// on every operation, surfaced under the root `--schema` output's +/// `globalFlags` key per ADR-0006. Per-op flags (`--page-all`, +/// `--output PATH`) are NOT in this list — those surface via per-op +/// capability hints (`paginable`, `binaryResponse`). +fn global_flags() -> Vec { + vec![ + serde_json::json!({ + "flag": "--schema", + "description": "Emit the machine-readable command surface as JSON (agent-facing counterpart to --help)", + }), + serde_json::json!({ + "flag": "--dry-run", + "description": "Validate the request locally without sending it to the API", + }), + serde_json::json!({ + "flag": "--format", + "valueName": "FORMAT", + "description": "Output format: json, table, yaml, csv, raw, jsonl, http. Default: table when stdout is a TTY, json when piped", + }), + serde_json::json!({ + "flag": "--base-url", + "valueName": "URL", + "description": "Override the API base URL (e.g. for testing against a mock server)", + }), + serde_json::json!({ + "flag": format!("--{}", crate::user_agent::suffix_flag()), + "valueName": "TOKEN", + "description": format!( + "Product token appended to the User-Agent (e.g. my-app/1.0). Takes precedence over {}.", + crate::user_agent::suffix_env_segment() + ), + }), + serde_json::json!({ + "flag": "--quiet", + "description": "Suppress stdout output on success (errors still go to stderr)", + }), + serde_json::json!({ + "flag": "--debug", + "description": "Dump HTTP request and response to stderr", + }), + serde_json::json!({ + "flag": "--query", + "valueName": "EXPR", + "description": "JMESPath expression applied to the response before formatting. For streaming responses, events whose projection is null are suppressed (use as a per-event filter).", + }), + serde_json::json!({ + "flag": "--spec", + "description": "Print the effective OpenAPI spec (source + overlays + overrides merged) to stdout", + }), + serde_json::json!({ + "flag": "--spec-raw", + "description": "Print the byte-exact embedded source OpenAPI spec(s) to stdout", + }), + ] +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_op_path_extracts_chain() { + let cmd = clap::Command::new("test") + .subcommand( + clap::Command::new("users").subcommand(clap::Command::new("get")), + ); + let matches = cmd + .try_get_matches_from(["test", "users", "get"]) + .unwrap(); + let (path, _) = resolve_op_path(&matches); + assert_eq!(path, vec!["users".to_string(), "get".to_string()]); + } + + // ── Helpers for merge_binding_subtrees / resolve_binding_for_leaf ── + + /// Minimal Binding stub for unit-testing the merge helpers. + struct TestBinding { + name: String, + command: clap::Command, + } + + impl TestBinding { + fn new(name: &str, command: clap::Command) -> Self { + Self { name: name.to_string(), command } + } + } + + impl Binding for TestBinding { + fn name(&self) -> &str { &self.name } + fn set_cli_name(&mut self, _name: &str) {} + fn build_command(&self) -> Result { + Ok(self.command.clone()) + } + fn dispatch<'a>( + &'a self, + _root: &'a clap::ArgMatches, + _sub: &'a clap::ArgMatches, + _op: &'a [String], + ) -> crate::binding::BoxFuture<'a, Result> { + Box::pin(async { Ok(DispatchResult::Handled) }) + } + } + + fn p(s: &[&str]) -> Vec { + s.iter().map(|x| x.to_string()).collect() + } + + #[test] + fn merge_disjoint_top_levels() { + // Binding A: users → list ; Binding B: posts → get + let a = clap::Command::new("a") + .subcommand(clap::Command::new("users").subcommand(clap::Command::new("list"))); + let b = clap::Command::new("b") + .subcommand(clap::Command::new("posts").subcommand(clap::Command::new("get"))); + let bindings: Vec> = vec![ + Box::new(TestBinding::new("a", a)), + Box::new(TestBinding::new("b", b)), + ]; + + let (merged, leaf_map, _) = merge_binding_subtrees(&bindings).expect("merge ok"); + + // Both top-level groups present. + assert!(merged.find_subcommand("users").is_some(), "users should be present"); + assert!(merged.find_subcommand("posts").is_some(), "posts should be present"); + + // Both leaves owned by their respective binding indexes. + assert_eq!(resolve_binding_for_leaf(&p(&["users", "list"]), &leaf_map), Some(0)); + assert_eq!(resolve_binding_for_leaf(&p(&["posts", "get"]), &leaf_map), Some(1)); + } + + #[test] + fn merge_overlapping_top_levels_disjoint_leaves() { + // A: convai → agents → list ; B: convai → conversations → get + let a = clap::Command::new("a").subcommand( + clap::Command::new("convai") + .subcommand(clap::Command::new("agents").subcommand(clap::Command::new("list"))), + ); + let b = clap::Command::new("b").subcommand( + clap::Command::new("convai") + .subcommand( + clap::Command::new("conversations").subcommand(clap::Command::new("get")), + ), + ); + let bindings: Vec> = vec![ + Box::new(TestBinding::new("a", a)), + Box::new(TestBinding::new("b", b)), + ]; + + let (merged, leaf_map, _) = merge_binding_subtrees(&bindings).expect("merge ok"); + + let convai = merged + .find_subcommand("convai") + .expect("convai should be merged into one parent"); + assert!(convai.find_subcommand("agents").is_some(), "agents should be present"); + assert!( + convai.find_subcommand("conversations").is_some(), + "conversations should be present", + ); + + assert_eq!( + resolve_binding_for_leaf(&p(&["convai", "agents", "list"]), &leaf_map), + Some(0), + ); + assert_eq!( + resolve_binding_for_leaf(&p(&["convai", "conversations", "get"]), &leaf_map), + Some(1), + ); + } + + #[test] + fn merge_detects_leaf_collision() { + // Both bindings declare users → list (full leaf-path collision). + let a = clap::Command::new("a") + .subcommand(clap::Command::new("users").subcommand(clap::Command::new("list"))); + let b = clap::Command::new("b") + .subcommand(clap::Command::new("users").subcommand(clap::Command::new("list"))); + let bindings: Vec> = vec![ + Box::new(TestBinding::new("a", a)), + Box::new(TestBinding::new("b", b)), + ]; + + let err = merge_binding_subtrees(&bindings).expect_err("collision must error"); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("users list"), + "collision message must list path 'users list', got: {msg}", + ); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn merge_exempts_intrinsic_generate_skills_from_collision() { + // Two bindings that BOTH expose the binding-emitted intrinsic + // `generate-skills` top-level leaf plus disjoint user-facing + // subtrees. The intrinsic must NOT trip the collision detector + // (the deep-merge produces a single node for it); only true + // user-facing collisions should error. + let a = clap::Command::new("a") + .subcommand(clap::Command::new("users").subcommand(clap::Command::new("list"))) + .subcommand(clap::Command::new("generate-skills")); + let b = clap::Command::new("b") + .subcommand(clap::Command::new("posts").subcommand(clap::Command::new("get"))) + .subcommand(clap::Command::new("generate-skills")); + let bindings: Vec> = vec![ + Box::new(TestBinding::new("a", a)), + Box::new(TestBinding::new("b", b)), + ]; + + let (merged, _leaf_map, _) = + merge_binding_subtrees(&bindings).expect("intrinsic dedup should not error"); + + assert!( + merged.find_subcommand("generate-skills").is_some(), + "merged tree must keep a single `generate-skills` node", + ); + // Sanity check the disjoint user-facing subtrees survived too. + assert!(merged.find_subcommand("users").is_some()); + assert!(merged.find_subcommand("posts").is_some()); + } + + #[test] + fn graft_builtin_folds_into_spec_owned_group() { + // A spec that declares its own `auth` resource group keeps every + // operation in it, and still gets the built-in credential + // subcommands folded in alongside. + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .subcommand(clap::Command::new("me")) + .subcommand(clap::Command::new("revoke")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + + let auth = cli.find_subcommand("auth").expect("auth group survives"); + for name in ["me", "revoke", "login", "logout", "status"] { + assert!( + auth.find_subcommand(name).is_some(), + "`auth {name}` should be reachable", + ); + } + + // And the merged tree actually parses the spec-owned operation. + let matches = cli + .clone() + .try_get_matches_from(["root", "auth", "me"]) + .expect("`auth me` should parse"); + let (path, _) = resolve_op_path(&matches); + assert_eq!(path, p(&["auth", "me"])); + } + + #[test] + fn graft_builtin_wins_on_exact_leaf_collision() { + // A spec operation named exactly like a built-in loses to the + // framework leaf — same rule as `graft_subcommand`. + let spec = clap::Command::new("root").subcommand( + clap::Command::new("auth") + .subcommand(clap::Command::new("login").about("spec-owned")), + ); + let cli = graft_builtin_command(spec, crate::auth::login::build_auth_command()); + + let login = cli + .find_subcommand("auth") + .and_then(|a| a.find_subcommand("login")) + .expect("login present"); + assert!(login.get_arguments().any(|a| a.get_id() == "with-token")); + } + + #[test] + fn graft_builtin_registers_when_no_collision() { + let cli = graft_builtin_command( + clap::Command::new("root").subcommand(clap::Command::new("users")), + crate::auth::login::build_auth_command(), + ); + assert!(cli.find_subcommand("auth").is_some()); + assert!(cli.find_subcommand("users").is_some()); + } + + #[test] + fn resolve_binding_for_leaf_finds_owner() { + let leaf_map: LeafMap = vec![ + (0, p(&["users", "list"])), + (1, p(&["posts", "get"])), + (1, p(&["posts", "list"])), + ]; + + // Known path → Some(owner index). + assert_eq!(resolve_binding_for_leaf(&p(&["users", "list"]), &leaf_map), Some(0)); + assert_eq!(resolve_binding_for_leaf(&p(&["posts", "get"]), &leaf_map), Some(1)); + + // Unknown path → None. + assert_eq!(resolve_binding_for_leaf(&p(&["unknown"]), &leaf_map), None); + + // Empty path → None. + assert_eq!(resolve_binding_for_leaf(&[], &leaf_map), None); + } + + #[test] + fn cli_app_must_use() { + // This test verifies the builder compiles — #[must_use] + // would fire a warning if the value were dropped without use. + let _app = CliApp::new("test"); + } + + #[test] + fn deduplicate_after_help_removes_identical_blocks() { + let a = "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path".to_string(); + let b = "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path".to_string(); + let result = deduplicate_after_help(&[a, b]); + assert_eq!( + result, + "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path", + ); + } + + #[test] + fn deduplicate_after_help_preserves_unique_blocks() { + let a = "Auth:\n bearer via API_KEY".to_string(); + let b = "Environment variables:\n BOX_BASE_URL Override".to_string(); + let result = deduplicate_after_help(&[a, b]); + assert_eq!( + result, + "Auth:\n bearer via API_KEY\n\nEnvironment variables:\n BOX_BASE_URL Override", + ); + } + + #[test] + fn deduplicate_after_help_multiline_blocks_are_atomic() { + // Two bindings with identical multi-line env block but + // different auth blocks — env block appears once, both auth kept. + let env_block = "Environment variables:\n BOX_BASE_URL Override\n BOX_CA_BUNDLE Path"; + let a = format!("Auth:\n bearer via API_KEY\n\n{env_block}"); + let b = format!("Auth:\n basic via SECRET\n\n{env_block}"); + let result = deduplicate_after_help(&[a, b]); + assert_eq!( + result, + format!("Auth:\n bearer via API_KEY\n\n{env_block}\n\nAuth:\n basic via SECRET"), + ); + } + + #[test] + fn deduplicate_after_help_real_world_footer() { + // Simulates two bindings with the same binary name producing + // identical env var + standard-env-var blocks. + let section = "Environment variables:\n BOX_BASE_URL Override\n BOX_TIMEOUT_SECS Timeout\n\nStandard env vars are also honored."; + let result = deduplicate_after_help(&[section.to_string(), section.to_string()]); + assert_eq!(result, section); + } +} diff --git a/src/arg_source.rs b/src/arg_source.rs new file mode 100644 index 0000000..3111c95 --- /dev/null +++ b/src/arg_source.rs @@ -0,0 +1,229 @@ +//! Strategy trait for argument defaults. +//! +//! [`ArgSource`] resolves a default value for a CLI flag at runtime. +//! Named implementations cover env vars, files, literals, and chains. + +use serde_json::Value; + +use crate::binding::BoxFuture; +use crate::error::CliError; + +/// Async strategy for resolving a default argument value. +pub trait ArgSource: Send + Sync + 'static { + /// Resolve the default value. `None` means "no default available." + fn resolve(&self) -> BoxFuture<'_, Result, CliError>>; +} + +/// Read a trimmed env var. Empty string → `None`. +pub struct EnvArg { + var: String, +} + +impl EnvArg { + pub fn new(var: impl Into) -> Self { + Self { var: var.into() } + } +} + +impl ArgSource for EnvArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + Box::pin(async move { + match std::env::var(&self.var) { + Ok(v) => { + let trimmed = v.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(Value::String(trimmed))) + } + } + Err(_) => Ok(None), + } + }) + } +} + +/// Read and trim file contents. Missing file → `None`. `~` is expanded +/// against `$HOME`. +pub struct FileArg { + path: std::path::PathBuf, +} + +impl FileArg { + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + fn expand_tilde(path: &std::path::Path) -> std::path::PathBuf { + if let Ok(stripped) = path.strip_prefix("~") { + if let Ok(home) = std::env::var("HOME") { + return std::path::PathBuf::from(home).join(stripped); + } + } + path.to_path_buf() + } +} + +impl ArgSource for FileArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + let expanded = Self::expand_tilde(&self.path); + Box::pin(async move { + match tokio::fs::read_to_string(&expanded).await { + Ok(contents) => { + let trimmed = contents.trim().to_string(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(Value::String(trimmed))) + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(CliError::Other(anyhow::anyhow!( + "Failed to read {}: {e}", + expanded.display() + ))), + } + }) + } +} + +/// A baked-in default value. +pub struct LiteralArg { + value: Value, +} + +impl LiteralArg { + pub fn new(value: impl Into) -> Self { + Self { + value: value.into(), + } + } +} + +impl ArgSource for LiteralArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + let v = self.value.clone(); + Box::pin(async move { Ok(Some(v)) }) + } +} + +/// First source returning `Some` wins. +pub struct ChainArg { + sources: Vec>, +} + +impl ChainArg { + pub fn from_sources(sources: Vec>) -> Self { + Self { sources } + } +} + +impl ArgSource for ChainArg { + fn resolve(&self) -> BoxFuture<'_, Result, CliError>> { + Box::pin(async move { + for source in &self.sources { + if let Some(v) = source.resolve().await? { + return Ok(Some(v)); + } + } + Ok(None) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn env_arg_reads_value() { + std::env::set_var("TEST_ARG_SOURCE_1", "hello"); + let source = EnvArg::new("TEST_ARG_SOURCE_1"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("hello".into()))); + std::env::remove_var("TEST_ARG_SOURCE_1"); + } + + #[tokio::test] + async fn env_arg_empty_returns_none() { + std::env::set_var("TEST_ARG_SOURCE_2", " "); + let source = EnvArg::new("TEST_ARG_SOURCE_2"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + std::env::remove_var("TEST_ARG_SOURCE_2"); + } + + #[tokio::test] + async fn env_arg_missing_returns_none() { + let source = EnvArg::new("TEST_ARG_SOURCE_DEFINITELY_MISSING"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn file_arg_reads_and_trims() { + let dir = std::env::temp_dir().join("fern_test_arg_source"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("test_file.txt"); + std::fs::write(&path, " world \n").unwrap(); + let source = FileArg::new(&path); + let result = source.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("world".into()))); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn file_arg_missing_returns_none() { + let source = FileArg::new("/tmp/fern_test_nonexistent_file_arg_source"); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + } + + #[tokio::test] + async fn file_arg_empty_returns_none() { + let dir = std::env::temp_dir().join("fern_test_arg_source"); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("test_empty_file.txt"); + std::fs::write(&path, " \n").unwrap(); + let source = FileArg::new(&path); + let result = source.resolve().await.unwrap(); + assert_eq!(result, None); + let _ = std::fs::remove_file(&path); + } + + #[tokio::test] + async fn literal_arg() { + let source = LiteralArg::new(42); + let result = source.resolve().await.unwrap(); + assert_eq!(result, Some(Value::Number(42.into()))); + } + + #[tokio::test] + async fn chain_arg_first_wins() { + std::env::set_var("TEST_CHAIN_ARG_1", "from-env"); + let chain = ChainArg::from_sources(vec![ + Box::new(EnvArg::new("TEST_CHAIN_ARG_1")), + Box::new(LiteralArg::new("fallback")), + ]); + let result = chain.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("from-env".into()))); + std::env::remove_var("TEST_CHAIN_ARG_1"); + } + + #[tokio::test] + async fn chain_arg_falls_through() { + let chain = ChainArg::from_sources(vec![ + Box::new(EnvArg::new("TEST_CHAIN_MISSING_ENV")), + Box::new(LiteralArg::new("fallback")), + ]); + let result = chain.resolve().await.unwrap(); + assert_eq!(result, Some(Value::String("fallback".into()))); + } + + #[tokio::test] + async fn chain_arg_empty_returns_none() { + let chain = ChainArg::from_sources(vec![]); + let result = chain.resolve().await.unwrap(); + assert_eq!(result, None); + } +} diff --git a/src/asyncapi/agent.asyncapi.yaml b/src/asyncapi/agent.asyncapi.yaml new file mode 100644 index 0000000..f5a37fe --- /dev/null +++ b/src/asyncapi/agent.asyncapi.yaml @@ -0,0 +1,1425 @@ +asyncapi: "2.6.0" +info: + title: ElevenLabs Agents Contract + version: "1.0.0" + description: | + Real-time Agents with voice synthesis and transcription. + This WebSocket API enables bidirectional audio streaming and text messaging + between clients and AI agents. + + ## Event Flow + - Audio chunks are sent without type field for performance (detected by presence of user_audio_chunk) + - All other messages use explicit type field for routing + - Client events can be selectively enabled via conversation config + contact: + name: ElevenLabs API Support + url: https://elevenlabs.io/docs + email: support@elevenlabs.io + +servers: + production: + url: wss://api.elevenlabs.io + protocol: ws + description: Production WebSocket server (US/Global) + eu-residency: + url: wss://api.eu.residency.elevenlabs.io + protocol: ws + description: EU residency WebSocket server for GDPR compliance + in-residency: + url: wss://api.in.residency.elevenlabs.io + protocol: ws + description: India residency WebSocket server for data locality + webrtc: + url: wss://livekit.rtc.elevenlabs.io + protocol: ws + description: WebRTC/LiveKit server for real-time communication + development: + url: ws://localhost:8080 + protocol: ws + description: Local development server + +channels: + AgentMessages: + description: Main WebSocket channel for agents + publish: + summary: Messages sent from server to client + message: + oneOf: + # Server -> Client messages + - $ref: "#/components/messages/Audio" + - $ref: "#/components/messages/UserTranscript" + - $ref: "#/components/messages/TentativeUserTranscript" + - $ref: "#/components/messages/AgentResponse" + - $ref: "#/components/messages/AgentResponseCorrection" + - $ref: "#/components/messages/AgentChatResponsePart" + - $ref: "#/components/messages/Interruption" + - $ref: "#/components/messages/ConversationMetadata" + - $ref: "#/components/messages/ClientToolCallMessage" + - $ref: "#/components/messages/AgentToolRequestMessage" + - $ref: "#/components/messages/AgentToolResponseMessage" + - $ref: "#/components/messages/AgentToolResponseFullPayloadMessage" + - $ref: "#/components/messages/MCPToolCall" + - $ref: "#/components/messages/MCPConnectionStatusMessage" + - $ref: "#/components/messages/VADScore" + - $ref: "#/components/messages/Ping" + - $ref: "#/components/messages/ASRInitiationMetadata" + - $ref: "#/components/messages/GuardrailTriggered" + - $ref: "#/components/messages/InternalTurnProbability" + - $ref: "#/components/messages/InternalTentativeAgentResponse" + - $ref: "#/components/messages/ErrorMessage" + subscribe: + summary: Messages received from client to server + message: + oneOf: + # Client -> Server messages + - $ref: "#/components/messages/UserAudio" + - $ref: "#/components/messages/Pong" + - $ref: "#/components/messages/UserMessage" + - $ref: "#/components/messages/UserActivity" + - $ref: "#/components/messages/UserFeedback" + - $ref: "#/components/messages/ClientToolResult" + - $ref: "#/components/messages/MCPToolApprovalResult" + - $ref: "#/components/messages/ContextualUpdate" + - $ref: "#/components/messages/ConversationInitiation" + - $ref: "#/components/messages/MultimodalMessage" +components: + messages: + # ===== CLIENT → SERVER MESSAGES ===== + + UserAudio: + name: UserAudio + title: User Audio Chunk + summary: Audio data from user (no type field for performance optimization) + contentType: application/json + payload: + $ref: "#/components/schemas/UserAudioPayload" + + Pong: + name: PongClientToOrchestratorEvent + title: Pong Response + summary: Response to server ping for latency measurement + contentType: application/json + payload: + $ref: "#/components/schemas/PongPayload" + + UserMessage: + name: UserMessageClientToOrchestratorEvent + title: User Text Message + summary: Text message from user + contentType: application/json + payload: + $ref: "#/components/schemas/UserMessagePayload" + + UserActivity: + name: UserActivityClientToOrchestratorEvent + title: User Activity Signal + summary: Signal that user is active (typing, etc.) + contentType: application/json + payload: + $ref: "#/components/schemas/UserActivityPayload" + + UserFeedback: + name: UserFeedbackClientToOrchestratorEvent + title: User Feedback + summary: User feedback on agent response + contentType: application/json + payload: + $ref: "#/components/schemas/UserFeedbackPayload" + + ClientToolResult: + name: ClientToolResultClientToOrchestratorEvent + title: Client Tool Result + summary: Result of client-side tool execution + contentType: application/json + payload: + $ref: "#/components/schemas/ClientToolResultPayload" + + MCPToolApprovalResult: + name: MCPToolApprovalResultClientToOrchestratorEvent + title: MCP Tool Approval Result + summary: User approval/rejection of MCP tool execution + contentType: application/json + payload: + $ref: "#/components/schemas/MCPToolApprovalResultPayload" + + ContextualUpdate: + name: ContextualUpdateClientToOrchestratorEvent + title: Contextual Update + summary: Non-interrupting context update + contentType: application/json + payload: + $ref: "#/components/schemas/ContextualUpdatePayload" + + ConversationInitiation: + name: ConversationInitiationClientToOrchestratorEvent + title: Conversation Initiation + summary: Initial configuration and overrides + contentType: application/json + payload: + $ref: "#/components/schemas/ConversationInitiationPayload" + + MultimodalMessage: + name: MultimodalMessageClientToOrchestratorEvent + title: Multimodal Message + summary: Multimodal message combining text and a file reference + contentType: application/json + payload: + $ref: "#/components/schemas/MultimodalMessagePayload" + + # ===== SERVER → CLIENT MESSAGES ===== + + Audio: + name: AudioClientEvent + title: Audio Output + summary: Synthesized audio from agent + contentType: application/json + payload: + $ref: "#/components/schemas/AudioPayload" + + UserTranscript: + name: UserTranscriptionClientEvent + title: User Transcript + summary: Final transcription of user speech + contentType: application/json + payload: + $ref: "#/components/schemas/UserTranscriptPayload" + + TentativeUserTranscript: + name: TentativeUserTranscriptionClientEvent + title: Tentative User Transcript + summary: In-progress transcription (may change) + contentType: application/json + payload: + $ref: "#/components/schemas/TentativeUserTranscriptPayload" + + AgentResponse: + name: AgentResponseClientEvent + title: Agent Response + summary: Agent's text response + contentType: application/json + payload: + $ref: "#/components/schemas/AgentResponsePayload" + + AgentResponseCorrection: + name: AgentResponseCorrectionClientEvent + title: Agent Response Correction + summary: Corrected response after interruption + contentType: application/json + payload: + $ref: "#/components/schemas/AgentResponseCorrectionPayload" + + AgentChatResponsePart: + name: AgentChatResponsePartClientEvent + title: Agent Chat Response Part + summary: Streaming text chunk from agent (text-only mode) + contentType: application/json + payload: + $ref: "#/components/schemas/AgentChatResponsePartPayload" + + Interruption: + name: InterruptionEvent + title: Interruption + summary: User interrupted agent speech + contentType: application/json + payload: + $ref: "#/components/schemas/InterruptionPayload" + + ConversationMetadata: + name: ConversationInitiationMetadataEvent + title: Conversation Metadata + summary: Initial connection metadata + contentType: application/json + payload: + $ref: "#/components/schemas/ConversationMetadataPayload" + + ClientToolCallMessage: + name: ClientToolCallClientEvent + title: Client Tool Call + summary: Tool for client to execute + contentType: application/json + payload: + $ref: "#/components/schemas/ClientToolCallPayload" + + AgentToolRequestMessage: + name: AgentToolRequestClientEvent + title: Agent Tool Request + summary: Event emitted when a tool request is received + contentType: application/json + payload: + $ref: "#/components/schemas/AgentToolRequestPayload" + + AgentToolResponseMessage: + name: AgentToolResponseClientEvent + title: Agent Tool Response + summary: Result of agent tool execution + contentType: application/json + payload: + $ref: "#/components/schemas/AgentToolResponsePayload" + + AgentToolResponseFullPayloadMessage: + name: AgentToolResponseFullPayloadClientEvent + title: Agent Tool Response Full Payload + summary: Tool response including the tool's full result payload as a string + contentType: application/json + payload: + $ref: "#/components/schemas/AgentToolResponseFullPayloadPayload" + + MCPToolCall: + name: MCPToolCallClientEvent + title: MCP Tool Call + summary: Model Context Protocol tool call + contentType: application/json + payload: + $ref: "#/components/schemas/MCPToolCallPayload" + + MCPConnectionStatusMessage: + name: MCPConnectionStatusClientEvent + title: MCP Connection Status + summary: Status of MCP service connections + contentType: application/json + payload: + $ref: "#/components/schemas/MCPConnectionStatusPayload" + + VADScore: + name: VADScoreClientEvent + title: VAD Score + summary: Voice Activity Detection score + contentType: application/json + payload: + $ref: "#/components/schemas/VADScorePayload" + + Ping: + name: PingEvent + title: Ping + summary: Keepalive ping from server + contentType: application/json + payload: + $ref: "#/components/schemas/PingPayload" + + ASRInitiationMetadata: + name: ASRInitiationMetadataEvent + title: ASR Initiation Metadata + summary: ASR initialization metadata + contentType: application/json + payload: + $ref: "#/components/schemas/ASRInitiationMetadataPayload" + + GuardrailTriggered: + name: GuardrailTriggeredClientEvent + title: Guardrail Triggered + summary: Event emitted when a guardrail is triggered + contentType: application/json + payload: + $ref: "#/components/schemas/GuardrailTriggeredPayload" + + InternalTurnProbability: + name: TurnProbabilityInternalClientEvent + title: Internal Turn Probability + summary: Internal turn probability score (not for public use) + contentType: application/json + payload: + $ref: "#/components/schemas/InternalTurnProbabilityPayload" + + InternalTentativeAgentResponse: + name: TentativeAgentResponseInternalClientEvent + title: Internal Tentative Agent Response + summary: Internal tentative agent response (not for public use) + contentType: application/json + payload: + $ref: "#/components/schemas/InternalTentativeAgentResponsePayload" + + # ===== ERROR MESSAGES ===== + + ErrorMessage: + name: ErrorClientEvent + title: Error Message + summary: Error event sent when connection is closing due to an error + contentType: application/json + payload: + $ref: "#/components/schemas/ErrorPayload" + + schemas: + ClientEvent: + type: string + enum: + - audio + - agent_response + - agent_response_correction + - agent_chat_response_part + - interruption + - user_transcript + - tentative_user_transcript + - conversation_initiation_metadata + - client_tool_call + - agent_tool_request + - agent_tool_response + - agent_tool_response_full_payload + - mcp_tool_call + - mcp_connection_status + - vad_score + - ping + - asr_initiation_metadata + - guardrail_triggered + - internal_turn_probability + - internal_tentative_agent_response + description: Types of events that can be sent from server to client + + Language: + type: string + enum: + - en + - ja + - zh + - de + - hi + - fr + - ko + - pt + - pt-br + - it + - es + - id + - nl + - tr + - pl + - sv + - bg + - ro + - ar + - cs + - el + - fi + - ms + - da + - ta + - uk + - ru + - hu + - hr + - sk + - no + - vi + - tl + - af + - hy + - as + - az + - be + - bn + - bs + - ca + - et + - gl + - ka + - gu + - ha + - he + - is + - ga + - jv + - kn + - kk + - ky + - lv + - lt + - lb + - mk + - ml + - mr + - ne + - ps + - fa + - pa + - sr + - sd + - sl + - so + - sw + - te + - th + - ur + - cy + description: Language code for ASR and TTS + + AudioFormat: + type: string + enum: + - pcm_8000 + - pcm_16000 + - pcm_22050 + - pcm_24000 + - pcm_44100 + - pcm_48000 + - ulaw_8000 + description: Audio encoding format + + UserInputAudioFormat: + type: string + enum: + - pcm_8000 + - pcm_16000 + - pcm_22050 + - pcm_24000 + - pcm_44100 + - pcm_48000 + - ulaw_8000 + description: Audio encoding format for user input + + FeedbackScore: + type: string + enum: + - like + - dislike + description: User's feedback score + + MCPIntegrationType: + type: string + enum: + - mcp_server + - mcp_integration + description: Type of MCP integration + + MCPToolCallBase: + type: object + required: [service_id, tool_call_id, tool_name, parameters, timestamp] + additionalProperties: false + properties: + service_id: + type: string + description: ID of the MCP service + tool_call_id: + type: string + description: Unique identifier for this tool call + tool_name: + type: string + description: Name of the tool being called + tool_description: + type: string + nullable: true + description: Optional description of the tool + parameters: + type: object + description: Parameters passed to the tool + timestamp: + type: string + format: date-time + description: ISO 8601 timestamp of the tool call + + MCPToolCallLoading: + allOf: + - $ref: "#/components/schemas/MCPToolCallBase" + - type: object + required: [state] + additionalProperties: false + properties: + state: + type: string + const: loading + description: Tool call is being executed + + MCPToolCallAwaitingApproval: + allOf: + - $ref: "#/components/schemas/MCPToolCallBase" + - type: object + required: [state, approval_timeout_secs] + additionalProperties: false + properties: + state: + type: string + const: awaiting_approval + description: Tool call requires user approval + approval_timeout_secs: + type: integer + default: 300 + description: Timeout in seconds for user approval + + MCPToolCallSuccess: + allOf: + - $ref: "#/components/schemas/MCPToolCallBase" + - type: object + required: [state, result] + additionalProperties: false + properties: + state: + type: string + const: success + description: Tool call completed successfully + result: + type: array + description: Array of content blocks returned by the tool + items: + type: object + description: ContentBlock from MCP + + MCPToolCallFailure: + allOf: + - $ref: "#/components/schemas/MCPToolCallBase" + - type: object + required: [state, error_message] + additionalProperties: false + properties: + state: + type: string + const: failure + description: Tool call failed + error_message: + type: string + description: Error message describing the failure + + # ===== CLIENT MESSAGE PAYLOADS ===== + + UserAudioPayload: + type: object + required: [user_audio_chunk] + properties: + user_audio_chunk: + type: string + description: Base64 encoded PCM or μ-law audio chunk + format: base64 + + PongPayload: + type: object + required: [type, event_id] + properties: + type: + type: string + const: pong + event_id: + type: integer + description: Echo of ping event_id + + UserMessagePayload: + type: object + required: [type] + properties: + type: + type: string + const: user_message + text: + type: string + nullable: true + description: User's text message (null for text-only mode signal) + + UserActivityPayload: + type: object + required: [type] + properties: + type: + type: string + const: user_activity + + UserFeedbackPayload: + type: object + required: [type, event_id, score] + properties: + type: + type: string + const: feedback + event_id: + type: integer + description: ID of the event being given feedback on + score: + $ref: "#/components/schemas/FeedbackScore" + + ClientToolResultPayload: + type: object + required: [type, tool_call_id, result, is_error] + properties: + type: + type: string + const: client_tool_result + tool_call_id: + type: string + result: + type: string + is_error: + type: boolean + + MCPToolApprovalResultPayload: + type: object + required: [type, tool_call_id, is_approved] + properties: + type: + type: string + const: mcp_tool_approval_result + tool_call_id: + type: string + is_approved: + type: boolean + + ContextualUpdatePayload: + type: object + required: [type, text] + properties: + type: + type: string + const: contextual_update + text: + type: string + context_id: + type: string + description: Optional identifier for deduplicating contextual updates. When set, only the most recent update with a given context_id is kept in the LLM context. + + ConversationInitiationPayload: + type: object + required: [type] + properties: + type: + type: string + const: conversation_initiation_client_data + conversation_config_override: + $ref: "#/components/schemas/ConversationConfigClientOverride" + custom_llm_extra_body: + type: object + description: Additional parameters passed to the LLM provider + dynamic_variables: + type: object + description: Dynamic variables available in the conversation context + user_id: + type: string + nullable: true + description: Unique identifier for the user + source_info: + $ref: "#/components/schemas/SourceInfo" + tool_mock_config: + $ref: "#/components/schemas/ToolMockConfig" + + ToolMockConfig: + type: object + description: | + Configuration for mocking tool behavior during conversations. + All fields are optional - an empty object or omitted fields use defaults: + - mocking_strategy defaults to 'none' (no mocking) + - fallback_strategy defaults to 'raise_error' (fail safely if mock unavailable) + - mocked_tool_names defaults to empty array (no specific tools selected) + properties: + mocking_strategy: + type: string + enum: [none, all, selected] + default: none + description: "Which tools to mock. Defaults to 'none'. Options: 'none' disables mocking, 'all' mocks every tool, 'selected' mocks only tools in mocked_tool_names" + mocked_tool_names: + type: array + items: + type: string + description: Tool names to mock when mocking_strategy is 'selected'. Only relevant when mocking_strategy is 'selected'. + fallback_strategy: + type: string + enum: [raise_error, call_real_tool] + default: raise_error + description: "Behavior when a mocked tool is called but no mock response matches. Defaults to 'raise_error' for safety. Options: 'raise_error' fails the tool call, 'call_real_tool' executes the actual tool" + + MultimodalMessageFileData: + type: object + required: [type, file_id] + properties: + type: + type: string + const: file_input + file_id: + type: string + description: The unique identifier of the file to include in the message + + MultimodalMessagePayload: + type: object + required: [type] + properties: + type: + type: string + const: multimodal_message + text: + $ref: "#/components/schemas/UserMessagePayload" + nullable: true + description: The text component of the multimodal message + file: + $ref: "#/components/schemas/MultimodalMessageFileData" + nullable: true + description: The file component of the multimodal message + + # ===== SERVER MESSAGE PAYLOADS ===== + + AudioPayload: + type: object + required: [audio_event, type] + properties: + type: + type: string + const: audio + audio_event: + $ref: "#/components/schemas/AudioEventData" + + UserTranscriptPayload: + type: object + required: [user_transcription_event, type] + properties: + type: + type: string + const: user_transcript + user_transcription_event: + $ref: "#/components/schemas/UserTranscriptionData" + + TentativeUserTranscriptPayload: + type: object + required: [tentative_user_transcription_event, type] + properties: + type: + type: string + const: tentative_user_transcript + tentative_user_transcription_event: + $ref: "#/components/schemas/TentativeTranscriptionData" + + AgentResponsePayload: + type: object + required: [agent_response_event, type] + properties: + type: + type: string + const: agent_response + agent_response_event: + $ref: "#/components/schemas/AgentResponseData" + + AgentResponseCorrectionPayload: + type: object + required: [agent_response_correction_event, type] + properties: + type: + type: string + const: agent_response_correction + agent_response_correction_event: + $ref: "#/components/schemas/AgentResponseCorrectionData" + + AgentChatResponsePartPayload: + type: object + required: [text_response_part, type] + properties: + type: + type: string + const: agent_chat_response_part + text_response_part: + $ref: "#/components/schemas/AgentChatResponsePartData" + + InterruptionPayload: + type: object + required: [interruption_event, type] + properties: + type: + type: string + const: interruption + interruption_event: + $ref: "#/components/schemas/InterruptionData" + + ConversationMetadataPayload: + type: object + required: [conversation_initiation_metadata_event, type] + properties: + type: + type: string + const: conversation_initiation_metadata + conversation_initiation_metadata_event: + $ref: "#/components/schemas/ConversationMetadataData" + + ClientToolCallPayload: + type: object + required: [client_tool_call, type] + properties: + type: + type: string + const: client_tool_call + client_tool_call: + $ref: "#/components/schemas/ClientToolCallData" + AgentToolRequestPayload: + type: object + required: [agent_tool_request, type] + properties: + type: + type: string + const: agent_tool_request + agent_tool_request: + $ref: "#/components/schemas/AgentToolRequestData" + AgentToolResponsePayload: + type: object + required: [agent_tool_response, type] + properties: + type: + type: string + const: agent_tool_response + agent_tool_response: + $ref: "#/components/schemas/AgentToolResponseData" + + AgentToolResponseFullPayloadPayload: + type: object + required: [agent_tool_response_full_payload, type] + properties: + type: + type: string + const: agent_tool_response_full_payload + agent_tool_response_full_payload: + $ref: "#/components/schemas/AgentToolResponseFullPayloadData" + + MCPToolCallPayload: + type: object + required: [mcp_tool_call, type] + properties: + type: + type: string + const: mcp_tool_call + mcp_tool_call: + type: object + description: MCP tool call data (polymorphic based on state) + oneOf: + - $ref: "#/components/schemas/MCPToolCallLoading" + - $ref: "#/components/schemas/MCPToolCallAwaitingApproval" + - $ref: "#/components/schemas/MCPToolCallSuccess" + - $ref: "#/components/schemas/MCPToolCallFailure" + + MCPConnectionStatusPayload: + type: object + required: [mcp_connection_status, type] + properties: + type: + type: string + const: mcp_connection_status + mcp_connection_status: + $ref: "#/components/schemas/MCPConnectionStatusData" + + VADScorePayload: + type: object + required: [vad_score_event, type] + properties: + type: + type: string + const: vad_score + vad_score_event: + $ref: "#/components/schemas/VADScoreData" + + PingPayload: + type: object + required: [ping_event, type] + properties: + type: + type: string + const: ping + ping_event: + $ref: "#/components/schemas/PingData" + + # ===== NAMED SCHEMAS FOR EVENT DATA ===== + + AudioEventData: + type: object + required: [audio_base_64, event_id] + properties: + audio_base_64: + type: string + format: base64 + event_id: + type: integer + alignment: + $ref: "#/components/schemas/AudioAlignmentData" + + UserTranscriptionData: + type: object + required: [user_transcript, event_id] + properties: + user_transcript: + type: string + event_id: + type: integer + + TentativeTranscriptionData: + type: object + required: [user_transcript, event_id] + properties: + user_transcript: + type: string + event_id: + type: integer + + AgentResponseData: + type: object + required: [agent_response, event_id] + properties: + agent_response: + type: string + event_id: + type: integer + + AgentResponseCorrectionData: + type: object + required: [original_agent_response, corrected_agent_response, event_id] + properties: + original_agent_response: + type: string + corrected_agent_response: + type: string + event_id: + type: integer + + AgentChatResponsePartType: + type: string + enum: + - start + - delta + - stop + description: Type of streaming response chunk + + AgentChatResponsePartData: + type: object + required: [text, type, event_id] + properties: + text: + type: string + description: Text chunk (empty for start/stop events) + type: + $ref: "#/components/schemas/AgentChatResponsePartType" + event_id: + type: integer + description: Event ID of this chat response part + + InterruptionData: + type: object + required: [event_id] + properties: + event_id: + type: integer + + ConversationMetadataData: + type: object + required: + [conversation_id, agent_output_audio_format, user_input_audio_format] + properties: + conversation_id: + type: string + agent_output_audio_format: + $ref: "#/components/schemas/AudioFormat" + user_input_audio_format: + $ref: "#/components/schemas/UserInputAudioFormat" + + ClientToolCallData: + type: object + required: [tool_name, tool_call_id, parameters, event_id] + properties: + tool_name: + type: string + tool_call_id: + type: string + parameters: + type: object + event_id: + type: integer + + AgentToolRequestData: + type: object + required: [tool_name, tool_call_id, tool_type, event_id] + properties: + tool_name: + type: string + description: Name of the tool being requested + tool_call_id: + type: string + description: Unique identifier for the tool call + tool_type: + type: string + description: Type of tool (client, webhook, or mcp) + event_id: + type: integer + description: Event ID when the tool was requested + + AgentToolResponseData: + type: object + required: + [tool_name, tool_call_id, tool_type, is_error, is_called, event_id] + properties: + tool_name: + type: string + description: Name of the tool that was executed + tool_call_id: + type: string + description: Unique identifier for the tool call + tool_type: + type: string + description: Type of tool (client, system, or mcp) + is_error: + type: boolean + description: Whether the tool execution resulted in an error + is_called: + type: boolean + description: Whether the tool has been called and execution started + event_id: + type: integer + + AgentToolResponseFullPayloadData: + type: object + required: + [ + tool_name, + tool_call_id, + tool_type, + is_error, + is_called, + event_id, + full_tool_result, + ] + properties: + tool_name: + type: string + description: Name of the tool that was executed + tool_call_id: + type: string + description: Unique identifier for the tool call + tool_type: + type: string + description: Type of tool (client, system, or mcp) + is_error: + type: boolean + description: Whether the tool execution resulted in an error + is_blocked: + type: boolean + default: false + description: Whether the tool call was blocked + is_called: + type: boolean + description: Whether the tool has been called and execution started + event_id: + type: integer + full_tool_result: + type: string + description: The tool's full result, forwarded verbatim as an opaque string + truncated: + type: boolean + default: false + description: Whether the full_tool_result was truncated to fit the size cap + + MCPConnectionStatusData: + type: object + required: [integrations] + properties: + integrations: + type: array + description: List of MCP integration statuses + items: + $ref: "#/components/schemas/MCPIntegrationStatus" + + MCPIntegrationStatus: + type: object + required: [integration_id, integration_type, is_connected, tool_count] + properties: + integration_id: + type: string + description: Unique identifier for the integration + integration_type: + $ref: "#/components/schemas/MCPIntegrationType" + is_connected: + type: boolean + description: Whether the integration is currently connected + tool_count: + type: integer + default: 0 + description: Number of tools available from this integration + + VADScoreData: + type: object + required: [vad_score] + properties: + vad_score: + type: number + minimum: 0 + maximum: 1 + + PingData: + type: object + required: [event_id] + properties: + event_id: + type: integer + ping_ms: + type: integer + nullable: true + description: Estimated round-trip time in milliseconds + + ASRInitiationMetadataPayload: + type: object + required: [asr_initiation_metadata_event, type] + properties: + type: + type: string + const: asr_initiation_metadata + asr_initiation_metadata_event: + type: object + description: ASR initialization metadata + + AudioAlignmentData: + type: object + required: [chars, char_start_times_ms, char_durations_ms] + properties: + chars: + type: array + items: + type: string + description: Array of individual characters in the synthesized text + char_start_times_ms: + type: array + items: + type: integer + description: Start time in milliseconds for each character + char_durations_ms: + type: array + items: + type: integer + description: Duration in milliseconds for each character + + GuardrailTriggeredPayload: + type: object + required: [type] + properties: + type: + type: string + const: guardrail_triggered + + InternalTurnProbabilityPayload: + type: object + required: [turn_probability_internal_event, type] + properties: + type: + type: string + const: internal_turn_probability + turn_probability_internal_event: + $ref: "#/components/schemas/InternalTurnProbabilityData" + + InternalTurnProbabilityData: + type: object + required: [turn_probability] + properties: + turn_probability: + type: number + minimum: 0 + maximum: 1 + description: Probability of turn-taking (0-1) + + InternalTentativeAgentResponsePayload: + type: object + required: [tentative_agent_response_internal_event, type] + properties: + type: + type: string + const: internal_tentative_agent_response + tentative_agent_response_internal_event: + $ref: "#/components/schemas/InternalTentativeAgentResponseData" + + InternalTentativeAgentResponseData: + type: object + required: [tentative_agent_response] + properties: + tentative_agent_response: + type: string + description: Tentative response from the agent (internal use) + + SourceInfo: + type: object + properties: + source: + type: string + nullable: true + description: Identifier of the client application + version: + type: string + nullable: true + description: Version of the client application + + # ===== CONVERSATION CONFIG OVERRIDE SCHEMAS ===== + + ConversationConfigClientOverride: + type: object + description: Client-side overrides for conversation configuration + properties: + agent: + $ref: "#/components/schemas/AgentConfigOverride" + tts: + $ref: "#/components/schemas/TTSConversationalConfigOverride" + conversation: + $ref: "#/components/schemas/ConversationConfigOverride" + + AgentConfigOverride: + type: object + description: Agent-specific configuration overrides + properties: + first_message: + type: string + nullable: true + description: Initial message the agent will say. If empty, agent waits for user to start + language: + $ref: "#/components/schemas/Language" + prompt: + $ref: "#/components/schemas/PromptAgentOverride" + native_mcp_server_ids: + type: array + nullable: true + maxItems: 10 + items: + type: string + description: List of Native MCP server IDs to be used by the agent + + PromptAgentOverride: + type: object + description: Agent prompt configuration override + properties: + prompt: + type: string + nullable: true + description: The system prompt that defines the agent's behavior + llm: + type: string + nullable: true + description: The LLM model to use for the conversation + + TTSConversationalConfigOverride: + type: object + description: Text-to-speech configuration overrides + properties: + voice_id: + type: string + nullable: true + description: ElevenLabs voice ID for speech synthesis + stability: + type: number + nullable: true + minimum: 0 + maximum: 1 + description: Voice stability (0-1). Lower values = more variable intonation + speed: + type: number + nullable: true + minimum: 0.7 + maximum: 5 + description: Speech speed multiplier (0.7-5). 1.0 = normal speed + similarity_boost: + type: number + nullable: true + minimum: 0 + maximum: 1 + description: Voice similarity boost (0-1). Higher values = more similar to original + + ConversationConfigOverride: + type: object + description: Conversation-specific configuration overrides + properties: + text_only: + type: boolean + nullable: true + description: If true, disables audio processing and uses text-only mode + client_events: + type: array + nullable: true + items: + $ref: "#/components/schemas/ClientEvent" + description: | + List of events to send to the client. If not specified, defaults to: + [audio, interruption, agent_response, user_transcript, tentative_user_transcript, + conversation_initiation_metadata, ping] + minItems: 1 + + # ===== ERROR MESSAGE SCHEMAS ===== + + ErrorPayload: + type: object + required: [type, error_event] + properties: + type: + type: string + const: error + error_event: + $ref: "#/components/schemas/ErrorData" + + ErrorData: + type: object + required: [code] + properties: + code: + $ref: "#/components/schemas/WebSocketCloseCode" + message: + type: string + description: Human-readable error description + error_type: + $ref: "#/components/schemas/ErrorType" + nullable: true + description: Specific error type for programmatic handling + reason: + type: string + nullable: true + description: Generic reason without sensitive details (sent to all clients) + debug_message: + type: string + nullable: true + description: Detailed debug message (only sent based on error reporting access level) + details: + type: object + additionalProperties: true + nullable: true + description: Additional error context + + WebSocketCloseCode: + type: integer + enum: [1000, 1002, 1008, 1011] + description: | + WebSocket close codes used by the conversation API: + - 1000: Normal closure (agent ended conversation, max duration exceeded) + - 1002: Protocol/operational error (timeouts, safety/policy violations, LLM/TTS/ASR failures, HTTP exceptions) + - 1008: Input/contract error (invalid messages, missing required fields, auth/validation errors) + - 1011: Internal server error (unexpected errors) + + ErrorType: + type: string + enum: + - unknown + - invalid_message + - telephony_agent_error + - mcp_tool_error + - mcp_https_error + - value_error + - missing_fields + - override_error + - missing_dynamic_variable_transfer + - missing_dynamic_variable + - websocket_disconnect + - safety_violation + - llm_timeout + - transport_receive_timeout + - asyncio_timeout + - http_exception + - max_duration_exceeded + - llm_error + - custom_llm_error + - cascade_brain_error + - asr_transcription_error + - vad_error + - turn_probability_error + - tts_cascade_error + - redis_timeout_error + - unknown_websocket_crash + description: | + Specific error types that can occur: + - unknown: Default/unknown error type + - invalid_message: Invalid JSON or WebSocket message format (code 1008) + - telephony_agent_error: ASR-only agents cannot handle telephony calls (code 1008) + - mcp_tool_error: Invalid MCP tool schema or conversion error (code 1008) + - mcp_https_error: MCP servers require HTTPS URLs (code 1008) + - value_error: ValueError in message processing (code 1008) + - missing_fields: Required fields missing from message (code 1008) + - override_error: Invalid override configuration (code 1008) + - missing_dynamic_variable_transfer: Missing dynamic variable after agent transfer (code 1008) + - missing_dynamic_variable: Required dynamic variable not provided (code 1008) + - websocket_disconnect: WebSocket disconnected + - safety_violation: Content policy violation detected (code 1002) + - llm_timeout: LLM response took over 20 seconds (code 1002) + - transport_receive_timeout: No user input for 60 seconds (code 1002) + - asyncio_timeout: Internal asyncio task timeout (code 1002) + - http_exception: HTTP exception converted to WebSocket error (code 1002) + - max_duration_exceeded: Max call duration exceeded (code 1000) + - llm_error: OpenAI or custom LLM error (code 1002) + - custom_llm_error: Custom LLM generation failed (code 1002) + - cascade_brain_error: All LLMs have failed (code 1002) + - asr_transcription_error: Audio transcription failed (code 1002) + - vad_error: Voice activity detection failed (code 1002) + - turn_probability_error: Turn detection failed (code 1002) + - tts_cascade_error: All TTS models have failed (code 1002) + - redis_timeout_error: Redis queries timed out (code 1002) + - unknown_websocket_crash: Unexpected server error (code 1011) diff --git a/src/asyncapi/app.rs b/src/asyncapi/app.rs new file mode 100644 index 0000000..ce724e6 --- /dev/null +++ b/src/asyncapi/app.rs @@ -0,0 +1,678 @@ +//! Slim builder for the AsyncAPI binding. +//! +//! Unlike the OpenAPI / GraphQL paths, the AsyncAPI binding does NOT +//! expose a top-level [`CliApp`]/`AppContext` for custom-command +//! handlers — that surface arrives in a later task. For now, this module +//! holds the inner state that [`AsyncApiBinding`](super::AsyncApiBinding) +//! delegates to: spec text, overlay text, endpoint override, builder- +//! level init-payload override, and an auth-bindings vec populated via +//! the `auth_scheme*` methods. +//! +//! Self-contained per the no-shared-abstractions rule +//! (`AGENTS.md` "Code Generation Model") — does NOT import from +//! `crate::openapi` or `crate::graphql`. We mirror the shape of +//! `src/graphql/app.rs` directly, by design. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::Value; + +use crate::auth::{ + build_provider_with_strategy, AuthCredentialSource, AuthStrategy, DynAuthProvider, + SchemeBinding, +}; +use crate::websocket::AutoResponder; + +/// Shape of a binding-level CLI arg registered via [`CliApp::cli_arg`]. +/// +/// AsyncAPI binaries sometimes need to steer the init payload or +/// autoresponder based on a flag the user passes on the command line +/// (e.g. ElevenLabs convai's `--voice` / `--audio-out`). The OpenAPI +/// and GraphQL bindings have `auth_scheme_cli` for the auth-credential +/// shape; this is the more general counterpart for the AsyncAPI path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BindingArgKind { + /// `--` boolean (clap `SetTrue`). With an env fallback, any + /// non-empty env value is treated as true — matches the + /// `is_ok_and(|v| !v.is_empty())` semantics ElevenLabs's + /// `ELEVENLABS_VOICE=1` was already using. + Flag, + /// `-- ` string. With an env fallback, a non-empty env + /// value is used verbatim. + Value, +} + +/// One registered binding-level CLI arg. +pub(crate) struct BindingArgSpec { + pub(crate) name: String, + pub(crate) kind: BindingArgKind, + pub(crate) help: String, + pub(crate) env_fallback: Option, +} + +/// Resolved binding-arg values, handed to the closures registered via +/// [`CliApp::init_payload_with`] and [`CliApp::autoresponder_with`] so +/// the binding can pick a payload / responder at dispatch time based on +/// what the user passed on the command line. +pub struct BindingArgs { + flags: HashMap, + values: HashMap, +} + +impl BindingArgs { + /// Build an empty resolver. Tests and the no-args dispatch path use + /// this; production callers go through [`CliApp::resolve_binding_args`]. + pub(crate) fn empty() -> Self { + Self { + flags: HashMap::new(), + values: HashMap::new(), + } + } + + pub(crate) fn insert_flag(&mut self, name: &str, value: bool) { + self.flags.insert(name.to_string(), value); + } + + pub(crate) fn insert_value(&mut self, name: &str, value: String) { + self.values.insert(name.to_string(), value); + } + + /// Read a registered boolean flag. Returns `false` if the arg name + /// is unknown or wasn't supplied (and had no env fallback). + pub fn flag(&self, name: &str) -> bool { + self.flags.get(name).copied().unwrap_or(false) + } + + /// Read a registered string value. Returns `None` if absent. + pub fn value(&self, name: &str) -> Option<&str> { + self.values.get(name).map(String::as_str) + } +} + +/// Dynamic init-payload picker. Returning `None` falls back to the +/// static `init_payload` (if set), then to the channel's +/// `x-fern-init-payload`. +type DynInitPayload = Arc Option + Send + Sync + 'static>; + +/// Dynamic autoresponder picker. Returning `None` falls back to the +/// static `autoresponder` (if set). +type DynAutoResponder = + Arc Option + Send + Sync + 'static>; + +/// Builder for the AsyncAPI binding's inner state. +#[allow(dead_code)] // Several fields wired for future tasks (overlay, endpoint, init). +pub struct CliApp { + pub(crate) name: String, + pub(crate) spec_yaml: Option, + pub(crate) overlay_yaml: Option, + pub(crate) endpoint_url: Option, + pub(crate) explicit_init_payload: Option, + pub(crate) dynamic_init_payload: Option, + pub(crate) auto_responder: Option, + pub(crate) dynamic_auto_responder: Option, + pub(crate) binding_args: Vec, + pub(crate) auth_bindings: Vec<(String, SchemeBinding)>, + auth_strategy: AuthStrategy, + /// Trust roots parsed at builder-call time. Mirrors graphql / openapi. + pub(crate) extra_root_certs: Vec, + /// Raw PEM bytes for each extra trust root (threaded into HttpConfig). + pub(crate) extra_root_certs_pem: Vec>, +} + +#[allow(dead_code)] // Builder methods called from AsyncApiBinding wrappers. +impl CliApp { + /// Create a new AsyncAPI CliApp with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + spec_yaml: None, + overlay_yaml: None, + endpoint_url: None, + explicit_init_payload: None, + dynamic_init_payload: None, + auto_responder: None, + dynamic_auto_responder: None, + binding_args: Vec::new(), + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + } + } + + /// Set the AsyncAPI YAML/JSON spec string. Typically `include_str!`. + pub fn spec(mut self, yaml: &str) -> Self { + self.spec_yaml = Some(yaml.to_string()); + self + } + + /// Set an overlay YAML/JSON string. Applied to the spec at + /// prepare-time before parsing into `AsyncApiDescription`. + pub fn overlay(mut self, yaml: &str) -> Self { + self.overlay_yaml = Some(yaml.to_string()); + self + } + + /// Override the WebSocket endpoint URL (replaces the spec's first + /// server URL). + pub fn endpoint(mut self, url: &str) -> Self { + self.endpoint_url = Some(url.to_string()); + self + } + + /// Builder-level explicit init payload — wins over an overlay's + /// `x-fern-init-payload` at the channel level. + pub fn init_payload(mut self, payload: Value) -> Self { + self.explicit_init_payload = Some(payload); + self + } + + /// Install a customer-owned autoresponder for the bidirectional REPL + /// path. Application-level keepalive (e.g. JSON `{"type":"ping"}` → + /// `{"type":"pong"}`) is API-specific with no cross-API standard, so + /// the framework provides only the [`AutoResponder`] primitive and + /// lets the customer ship the per-API closure from their binary. + pub fn autoresponder(mut self, responder: AutoResponder) -> Self { + self.auto_responder = Some(responder); + self + } + + /// Register a binding-level CLI arg attached as a global flag on the + /// root command. Read the resolved value inside an + /// [`init_payload_with`](Self::init_payload_with) or + /// [`autoresponder_with`](Self::autoresponder_with) closure via the + /// [`BindingArgs`] resolver. + /// + /// `arg_name` is the kebab-cased long form (`"voice"`, + /// `"audio-out"`) without the leading `--`. The arg is attached as a + /// clap global on the binding's root, so it's reachable from any + /// channel leaf without per-channel wiring — same shape as + /// `--base-url` / `--dry-run`. + /// + /// This is the AsyncAPI counterpart to `auth_scheme_cli` on the + /// OpenAPI / GraphQL bindings: a per-binding hook that lets a + /// binary inject a flag onto the spec-driven command tree without + /// the schema knowing anything about it. + /// + /// # Collision + /// + /// Clap rejects duplicate ids at command-tree build time, so a + /// binding arg whose name collides with a channel parameter (or + /// another binding's global) panics up front rather than silently + /// shadowing. + pub fn cli_arg(mut self, arg_name: &str, kind: BindingArgKind, help: &str) -> Self { + self.binding_args.push(BindingArgSpec { + name: arg_name.to_string(), + kind, + help: help.to_string(), + env_fallback: None, + }); + self + } + + /// Like [`cli_arg`](Self::cli_arg) but with an environment-variable + /// fallback used when the flag is absent on the command line. + /// Mirrors the `cli > env` resolution shape used by + /// `AuthCredentialSource::any([cli, env])` in the auth path. + /// + /// For a [`BindingArgKind::Flag`], any non-empty env value resolves + /// to true. For a [`BindingArgKind::Value`], the env value is used + /// verbatim when non-empty. + pub fn cli_arg_env( + mut self, + arg_name: &str, + kind: BindingArgKind, + help: &str, + env_var: &str, + ) -> Self { + self.binding_args.push(BindingArgSpec { + name: arg_name.to_string(), + kind, + help: help.to_string(), + env_fallback: Some(env_var.to_string()), + }); + self + } + + /// Dynamic counterpart to [`init_payload`](Self::init_payload) — + /// picks the payload at dispatch time from the resolved binding + /// args. Returning `None` falls back to the static + /// [`init_payload`](Self::init_payload) (if set), then to the + /// channel's `x-fern-init-payload`. + pub fn init_payload_with(mut self, f: F) -> Self + where + F: Fn(&BindingArgs) -> Option + Send + Sync + 'static, + { + self.dynamic_init_payload = Some(Arc::new(f)); + self + } + + /// Dynamic counterpart to [`autoresponder`](Self::autoresponder) — + /// picks the autoresponder at dispatch time from the resolved + /// binding args. Returning `None` falls back to the static + /// [`autoresponder`](Self::autoresponder) (if set). + pub fn autoresponder_with(mut self, f: F) -> Self + where + F: Fn(&BindingArgs) -> Option + Send + Sync + 'static, + { + self.dynamic_auto_responder = Some(Arc::new(f)); + self + } + + /// Walk the registered binding args, read each from `root_matches` + /// (and fall back to the env var if registered + absent), and + /// produce a resolver that the init-payload / autoresponder + /// closures can consult. + pub(crate) fn resolve_binding_args(&self, root_matches: &clap::ArgMatches) -> BindingArgs { + let mut resolved = BindingArgs::empty(); + for spec in &self.binding_args { + match spec.kind { + BindingArgKind::Flag => { + // clap `try_get_one::` is `Ok(Some(true))` when + // present, `Ok(Some(false))` when absent on a SetTrue + // arg, and `Err(_)` only if the arg id isn't + // registered — defensive read in case build_command + // somehow didn't attach it (would be a framework bug, + // not user input, so degrade silently). + let from_cli = root_matches + .try_get_one::(&spec.name) + .ok() + .flatten() + .copied() + .unwrap_or(false); + let resolved_value = if from_cli { + true + } else { + spec.env_fallback + .as_deref() + .and_then(|env| std::env::var(env).ok()) + .map(|v| !v.is_empty()) + .unwrap_or(false) + }; + resolved.insert_flag(&spec.name, resolved_value); + } + BindingArgKind::Value => { + let from_cli = root_matches + .try_get_one::(&spec.name) + .ok() + .flatten() + .cloned(); + let resolved_value = from_cli.or_else(|| { + spec.env_fallback + .as_deref() + .and_then(|env| std::env::var(env).ok()) + .filter(|v| !v.is_empty()) + }); + if let Some(v) = resolved_value { + resolved.insert_value(&spec.name, v); + } + } + } + } + resolved + } + + /// Pick the init payload that should be sent on connect, considering + /// the dynamic closure first, then the static value. Returning + /// `None` lets the executor fall back to the channel's + /// `x-fern-init-payload`. + pub(crate) fn select_init_payload(&self, args: &BindingArgs) -> Option { + if let Some(ref f) = self.dynamic_init_payload { + if let Some(v) = f(args) { + return Some(v); + } + } + self.explicit_init_payload.clone() + } + + /// Pick the autoresponder to install on the REPL path, dynamic first + /// then static. Returning `None` is fine — the executor handles the + /// no-responder case. + pub(crate) fn select_autoresponder(&self, args: &BindingArgs) -> Option { + if let Some(ref f) = self.dynamic_auto_responder { + if let Some(r) = f(args) { + return Some(r); + } + } + self.auto_responder.clone() + } + + /// Shorthand: bind a named scheme to an env var. + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Bind a credential source to a named auth scheme. + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to an http-basic scheme. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Bind a fully-custom [`AuthProvider`][crate::auth::AuthProvider]. + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, Arc::new(provider)) + } + + /// Variant of [`auth_provider`](Self::auth_provider) for a pre-built + /// [`DynAuthProvider`]. + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Custom(provider))); + self + } + + /// Build the auth provider used at dispatch time. + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { + build_provider_with_strategy( + &self.auth_bindings, + &std::collections::HashMap::new(), + self.auth_strategy, + false, + ) + } + + /// Decorate a clap `Command` with the auth help section, matching + /// the openapi/graphql binding shape. + pub(crate) fn decorate_command(&self, mut cli: clap::Command) -> clap::Command { + let existing_after_help = cli.get_after_help().map(|s| s.to_string()); + let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + if existing_after_help.is_some() || auth_section.is_some() { + let mut sections: Vec<&str> = Vec::with_capacity(2); + if let Some(ref s) = existing_after_help { + sections.push(s); + } + if let Some(ref s) = auth_section { + sections.push(s); + } + cli = cli.after_help(sections.join("\n\n")); + } + cli + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cli_app_builder_records_spec_and_endpoint() { + let app = CliApp::new("ws-cli") + .spec("asyncapi: '2.6.0'") + .endpoint("wss://example.com"); + assert_eq!(app.name, "ws-cli"); + assert!(app.spec_yaml.is_some()); + assert_eq!(app.endpoint_url.as_deref(), Some("wss://example.com")); + } + + #[test] + fn cli_app_auth_scheme_records_binding() { + let app = CliApp::new("ws-cli") + .spec("asyncapi: '2.6.0'") + .auth_scheme_env("xi-api-key", "XI_API_KEY"); + assert_eq!(app.auth_bindings.len(), 1); + } + + #[test] + fn cli_app_init_payload_records_explicit_override() { + let app = CliApp::new("ws-cli") + .spec("asyncapi: '2.6.0'") + .init_payload(serde_json::json!({"type": "init"})); + assert!(app.explicit_init_payload.is_some()); + } + + // -- cli_arg / binding-arg resolver ------------------------------------ + + /// Build a minimal clap root carrying the binding-args declared on + /// `app` so we can exercise `resolve_binding_args` without spinning + /// up the full AsyncAPI command tree. + fn matches_with_app_args(app: &CliApp, argv: &[&str]) -> clap::ArgMatches { + let mut cli = clap::Command::new("root"); + for spec in &app.binding_args { + let arg = match spec.kind { + BindingArgKind::Flag => clap::Arg::new(spec.name.clone()) + .long(spec.name.clone()) + .action(clap::ArgAction::SetTrue) + .global(true), + BindingArgKind::Value => clap::Arg::new(spec.name.clone()) + .long(spec.name.clone()) + .value_name("VALUE") + .global(true), + }; + cli = cli.arg(arg); + } + cli.try_get_matches_from(argv).expect("argv parses") + } + + #[test] + fn cli_arg_records_spec_with_no_env_fallback() { + let app = CliApp::new("ws-cli") + .cli_arg("voice", BindingArgKind::Flag, "Enable voice mode"); + assert_eq!(app.binding_args.len(), 1); + assert_eq!(app.binding_args[0].name, "voice"); + assert_eq!(app.binding_args[0].kind, BindingArgKind::Flag); + assert!(app.binding_args[0].env_fallback.is_none()); + } + + #[test] + fn cli_arg_env_records_fallback() { + let app = CliApp::new("ws-cli").cli_arg_env( + "audio-out", + BindingArgKind::Value, + "Capture decoded PCM", + "ELEVENLABS_AUDIO_OUT", + ); + assert_eq!( + app.binding_args[0].env_fallback.as_deref(), + Some("ELEVENLABS_AUDIO_OUT"), + ); + } + + #[test] + fn resolve_binding_args_reads_flag_from_cli() { + let app = CliApp::new("ws-cli").cli_arg("voice", BindingArgKind::Flag, ""); + let matches = matches_with_app_args(&app, &["root", "--voice"]); + let resolved = app.resolve_binding_args(&matches); + assert!(resolved.flag("voice")); + } + + #[test] + fn resolve_binding_args_flag_absent_is_false() { + let app = CliApp::new("ws-cli").cli_arg("voice", BindingArgKind::Flag, ""); + let matches = matches_with_app_args(&app, &["root"]); + let resolved = app.resolve_binding_args(&matches); + assert!(!resolved.flag("voice")); + } + + #[test] + fn resolve_binding_args_reads_value_from_cli() { + let app = CliApp::new("ws-cli").cli_arg("audio-out", BindingArgKind::Value, ""); + let matches = matches_with_app_args(&app, &["root", "--audio-out", "reply.pcm"]); + let resolved = app.resolve_binding_args(&matches); + assert_eq!(resolved.value("audio-out"), Some("reply.pcm")); + } + + #[test] + fn resolve_binding_args_value_absent_returns_none() { + let app = CliApp::new("ws-cli").cli_arg("audio-out", BindingArgKind::Value, ""); + let matches = matches_with_app_args(&app, &["root"]); + let resolved = app.resolve_binding_args(&matches); + assert_eq!(resolved.value("audio-out"), None); + } + + /// Pick a process-unique env name so parallel cargo tests don't + /// interfere (no `Date.now()` available; the test's pid + line is + /// enough entropy). + fn unique_env(stem: &str) -> String { + format!("ASYNCAPI_TEST_{}_{}", stem.to_uppercase(), std::process::id()) + } + + #[test] + fn resolve_binding_args_env_fallback_promotes_nonempty_to_true_flag() { + let env_name = unique_env("voice_flag"); + std::env::set_var(&env_name, "1"); + let app = CliApp::new("ws-cli").cli_arg_env( + "voice", + BindingArgKind::Flag, + "", + &env_name, + ); + let matches = matches_with_app_args(&app, &["root"]); + let resolved = app.resolve_binding_args(&matches); + assert!(resolved.flag("voice")); + std::env::remove_var(&env_name); + } + + #[test] + fn resolve_binding_args_env_fallback_empty_string_leaves_flag_false() { + let env_name = unique_env("voice_empty"); + std::env::set_var(&env_name, ""); + let app = CliApp::new("ws-cli").cli_arg_env( + "voice", + BindingArgKind::Flag, + "", + &env_name, + ); + let matches = matches_with_app_args(&app, &["root"]); + let resolved = app.resolve_binding_args(&matches); + assert!( + !resolved.flag("voice"), + "empty env value must not promote flag to true", + ); + std::env::remove_var(&env_name); + } + + #[test] + fn resolve_binding_args_cli_value_overrides_env_fallback() { + let env_name = unique_env("audio_out_override"); + std::env::set_var(&env_name, "from-env.pcm"); + let app = CliApp::new("ws-cli").cli_arg_env( + "audio-out", + BindingArgKind::Value, + "", + &env_name, + ); + let matches = matches_with_app_args(&app, &["root", "--audio-out", "from-cli.pcm"]); + let resolved = app.resolve_binding_args(&matches); + assert_eq!(resolved.value("audio-out"), Some("from-cli.pcm")); + std::env::remove_var(&env_name); + } + + #[test] + fn resolve_binding_args_env_value_fallback_used_when_cli_absent() { + let env_name = unique_env("audio_out_envonly"); + std::env::set_var(&env_name, "from-env.pcm"); + let app = CliApp::new("ws-cli").cli_arg_env( + "audio-out", + BindingArgKind::Value, + "", + &env_name, + ); + let matches = matches_with_app_args(&app, &["root"]); + let resolved = app.resolve_binding_args(&matches); + assert_eq!(resolved.value("audio-out"), Some("from-env.pcm")); + std::env::remove_var(&env_name); + } + + // -- select_init_payload / select_autoresponder pickers ---------------- + + #[test] + fn select_init_payload_dynamic_closure_wins_over_static() { + let app = CliApp::new("ws-cli") + .init_payload(serde_json::json!({"source": "static"})) + .cli_arg("voice", BindingArgKind::Flag, "") + .init_payload_with(|args| { + if args.flag("voice") { + Some(serde_json::json!({"source": "dynamic"})) + } else { + None + } + }); + let matches = matches_with_app_args(&app, &["root", "--voice"]); + let resolved = app.resolve_binding_args(&matches); + let picked = app.select_init_payload(&resolved).expect("Some"); + assert_eq!(picked["source"], "dynamic"); + } + + #[test] + fn select_init_payload_falls_back_to_static_when_dynamic_returns_none() { + let app = CliApp::new("ws-cli") + .init_payload(serde_json::json!({"source": "static"})) + .cli_arg("voice", BindingArgKind::Flag, "") + .init_payload_with(|_args| None); + let matches = matches_with_app_args(&app, &["root"]); + let resolved = app.resolve_binding_args(&matches); + let picked = app.select_init_payload(&resolved).expect("Some"); + assert_eq!(picked["source"], "static"); + } + + #[test] + fn select_init_payload_returns_none_when_neither_set() { + let app = CliApp::new("ws-cli"); + let resolved = BindingArgs::empty(); + assert!(app.select_init_payload(&resolved).is_none()); + } + + #[test] + fn select_autoresponder_dynamic_closure_wins_over_static() { + use crate::websocket::ResponderAction; + let static_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let static_clone = std::sync::Arc::clone(&static_called); + let dynamic_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let dynamic_clone = std::sync::Arc::clone(&dynamic_called); + + let static_responder: crate::websocket::AutoResponder = std::sync::Arc::new( + move |_frame: &serde_json::Value| -> Option { + static_clone.store(true, std::sync::atomic::Ordering::SeqCst); + None + }, + ); + let app = CliApp::new("ws-cli") + .autoresponder(static_responder) + .cli_arg("voice", BindingArgKind::Flag, "") + .autoresponder_with(move |args| { + if args.flag("voice") { + let flag = std::sync::Arc::clone(&dynamic_clone); + let responder: crate::websocket::AutoResponder = std::sync::Arc::new( + move |_frame: &serde_json::Value| -> Option { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + None + }, + ); + Some(responder) + } else { + None + } + }); + let matches = matches_with_app_args(&app, &["root", "--voice"]); + let resolved = app.resolve_binding_args(&matches); + let picked = app.select_autoresponder(&resolved).expect("Some"); + // Invoke the picked responder once; the dynamic closure's + // bool flips, the static one's does not. + let _ = picked(&serde_json::json!({})); + assert!(dynamic_called.load(std::sync::atomic::Ordering::SeqCst)); + assert!(!static_called.load(std::sync::atomic::Ordering::SeqCst)); + } +} diff --git a/src/asyncapi/binding.rs b/src/asyncapi/binding.rs new file mode 100644 index 0000000..d38d042 --- /dev/null +++ b/src/asyncapi/binding.rs @@ -0,0 +1,577 @@ +//! [`AsyncApiBinding`] — adapts the AsyncAPI path to the root +//! [`crate::binding::Binding`] trait so it can be composed into a +//! root-level [`crate::app::CliApp`] alongside an `OpenApiBinding` or +//! `GraphqlBinding`. +//! +//! Mirrors `src/openapi/binding.rs` and `src/graphql/binding.rs` — +//! intentional duplication per the no-shared-abstractions rule +//! (`AGENTS.md` "Code Generation Model"). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; + +use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::binding::{Binding, BoxFuture, DispatchResult}; +use crate::error::CliError; +use crate::http::HttpConfig; +use crate::websocket::AutoResponder; + +use super::app::{BindingArgKind, BindingArgs}; +use super::commands; +use super::discovery::AsyncApiDescription; +use super::executor; +use super::overlay::apply_overlays_to_spec; + +/// Prepared state computed once on first `build_command()` / `dispatch()`. +struct Prepared { + doc: AsyncApiDescription, + http_config: HttpConfig, +} + +/// An AsyncAPI binding that wraps [`super::CliApp`]'s internals and +/// exposes them through the [`Binding`] trait. +#[must_use] +pub struct AsyncApiBinding { + inner: super::CliApp, + prepared: Mutex>>, +} + +impl Default for AsyncApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: Mutex::new(None), + } + } +} + +impl AsyncApiBinding { + /// Create a new AsyncAPI binding. The CLI name is set automatically + /// by `CliApp::binding()` — no need to pass it here. + pub fn new() -> Self { + Self::default() + } + + pub fn spec(mut self, yaml: &str) -> Self { + self.inner = self.inner.spec(yaml); + self + } + + pub fn overlay(mut self, yaml: &str) -> Self { + self.inner = self.inner.overlay(yaml); + self + } + + pub fn endpoint(mut self, url: &str) -> Self { + self.inner = self.inner.endpoint(url); + self + } + + pub fn init_payload(mut self, payload: Value) -> Self { + self.inner = self.inner.init_payload(payload); + self + } + + /// Install a customer-owned autoresponder for application-level + /// keepalive (e.g. JSON ping/pong). See [`super::CliApp::autoresponder`]. + pub fn autoresponder(mut self, responder: AutoResponder) -> Self { + self.inner = self.inner.autoresponder(responder); + self + } + + /// Register a binding-level CLI arg as a clap global. See + /// [`super::CliApp::cli_arg`] for full semantics — this is just the + /// public wrapper on the binding. + pub fn cli_arg(mut self, arg_name: &str, kind: BindingArgKind, help: &str) -> Self { + self.inner = self.inner.cli_arg(arg_name, kind, help); + self + } + + /// [`cli_arg`](Self::cli_arg) with an env-var fallback. See + /// [`super::CliApp::cli_arg_env`]. + pub fn cli_arg_env( + mut self, + arg_name: &str, + kind: BindingArgKind, + help: &str, + env_var: &str, + ) -> Self { + self.inner = self.inner.cli_arg_env(arg_name, kind, help, env_var); + self + } + + /// Dynamic init-payload picker. See + /// [`super::CliApp::init_payload_with`]. + pub fn init_payload_with(mut self, f: F) -> Self + where + F: Fn(&BindingArgs) -> Option + Send + Sync + 'static, + { + self.inner = self.inner.init_payload_with(f); + self + } + + /// Dynamic autoresponder picker. See + /// [`super::CliApp::autoresponder_with`]. + pub fn autoresponder_with(mut self, f: F) -> Self + where + F: Fn(&BindingArgs) -> Option + Send + Sync + 'static, + { + self.inner = self.inner.autoresponder_with(f); + self + } + + pub fn auth_scheme_env(mut self, scheme_name: &str, env_var: &str) -> Self { + self.inner = self.inner.auth_scheme_env(scheme_name, env_var); + self + } + + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.inner = self.inner.auth_scheme(scheme_name, source); + self + } + + pub fn auth_provider

(mut self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.inner = self.inner.auth_provider(scheme_name, provider); + self + } + + fn ensure_prepared(&self) -> Result, CliError> { + let mut guard = self.prepared.lock().unwrap(); + if let Some(ref arc) = *guard { + return Ok(Arc::clone(arc)); + } + + let yaml = self.inner.spec_yaml.as_deref().ok_or_else(|| { + CliError::Discovery("No spec provided. Call .spec() on AsyncApiBinding.".to_string()) + })?; + + // Apply the overlay (if any) before parsing. + let spec_yaml = match self.inner.overlay_yaml.as_deref() { + Some(overlay) => apply_overlays_to_spec(yaml, &[overlay.to_string()])?, + None => yaml.to_string(), + }; + + let doc = super::parse(&spec_yaml)?; + + let http_config = HttpConfig::new(&self.inner.name)?.with_parsed_root_certs( + self.inner.extra_root_certs.iter().cloned(), + self.inner.extra_root_certs_pem.iter().cloned(), + ); + + let arc = Arc::new(Prepared { doc, http_config }); + *guard = Some(Arc::clone(&arc)); + Ok(arc) + } + + /// Resolve the matched channel from `clap::ArgMatches`. Walks the + /// subcommand chain collecting EVERY group + leaf name, then matches + /// against each channel's full path (`sdk_group_name ++ [leaf]`). + /// + /// Matching by leaf alone is wrong: two channels can share a method + /// name under different groups (e.g. `admin list` and `users list`) + /// and `doc.channels` is a `HashMap` with non-deterministic iteration, + /// so leaf-only matching would silently dispatch to whichever channel + /// the iterator yielded first. + fn resolve_channel<'a>( + doc: &'a AsyncApiDescription, + root_matches: &'a clap::ArgMatches, + ) -> Result<(&'a str, &'a super::discovery::Channel, &'a clap::ArgMatches), CliError> { + // Walk the full subcommand chain, capturing each segment. + let mut current_matches = root_matches; + let mut command_path: Vec = Vec::new(); + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + command_path.push(sub_name.to_string()); + current_matches = sub_matches; + } + if command_path.is_empty() { + return Err(CliError::Validation( + "No channel subcommand was matched".to_string(), + )); + } + + // Match the captured path against each channel's full path. Use + // the same registrar logic as `commands::build_cli` so empty + // `x-fern-sdk-method-name` strings fall back to the kebab-case + // channel name (registrar treats them as missing). + for (channel_name, channel) in &doc.channels { + let leaf = commands::leaf_command_name(channel_name, channel); + // Channel's full path == sdk_group_name ++ [leaf] + if command_path.len() == channel.sdk_group_name.len() + 1 + && command_path[..channel.sdk_group_name.len()] == channel.sdk_group_name[..] + && command_path[channel.sdk_group_name.len()] == leaf + { + return Ok((channel_name.as_str(), channel, current_matches)); + } + } + + Err(CliError::Validation(format!( + "Matched subcommand `{}` does not correspond to any declared AsyncAPI channel", + command_path.join(" "), + ))) + } +} + +impl Binding for AsyncApiBinding { + fn name(&self) -> &str { + &self.inner.name + } + + fn set_cli_name(&mut self, name: &str) { + self.inner.name = name.to_string(); + } + + fn set_root_auth(&mut self, bindings: &[(String, SchemeBinding)]) { + let mut merged = bindings.to_vec(); + merged.extend(std::mem::take(&mut self.inner.auth_bindings)); + self.inner.auth_bindings = merged; + } + + fn build_command(&self) -> Result { + let prepared = self.ensure_prepared()?; + let mut cli = commands::build_cli(&prepared.doc); + // Attach binding-level CLI args as globals on the root. Clap + // raises a duplicate-id error at parse time if a binding-arg + // name collides with a channel-parameter flag — that's the loud + // failure mode we want, not a silent shadow. + for spec in &self.inner.binding_args { + let arg = match spec.kind { + BindingArgKind::Flag => clap::Arg::new(spec.name.clone()) + .long(spec.name.clone()) + .help(spec.help.clone()) + .action(clap::ArgAction::SetTrue) + .global(true), + BindingArgKind::Value => clap::Arg::new(spec.name.clone()) + .long(spec.name.clone()) + .help(spec.help.clone()) + .value_name("VALUE") + .global(true), + }; + cli = cli.arg(arg); + } + Ok(self.inner.decorate_command(cli)) + } + + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + let prepared = match self.ensure_prepared() { + Ok(p) => p, + Err(e) => return Box::pin(async move { Err(e) }), + }; + + Box::pin(async move { + let (channel_name, channel, matched_args) = + Self::resolve_channel(&prepared.doc, root_matches)?; + + let message_arg = matched_args.get_one::("message").map(String::as_str); + + // `--dry-run` is a global flag. It is registered by the AsyncAPI + // command tree (`commands::build_cli`) and, in mixed apps, also by + // a sibling `OpenApiBinding` — either way it resolves on the leaf + // matches. Read it defensively (`try_get_one`) so a host app that + // somehow strips the flag degrades to "live" rather than panicking. + let dry_run = matched_args + .try_get_one::("dry-run") + .ok() + .flatten() + .copied() + .unwrap_or(false); + + // Collect URL-template parameters from the matched flags. + let mut param_args: HashMap = HashMap::new(); + for param_name in channel.parameters.keys() { + if let Some(value) = matched_args.get_one::(param_name) { + param_args.insert(param_name.clone(), value.clone()); + } + } + + // `--base-url` (or `_BASE_URL`) is a HOST-ONLY override: + // it swaps the scheme + authority but PRESERVES the path of + // the binding-configured endpoint URL. That way a wire test + // can point at `ws://127.0.0.1:` and still hit the + // binary's `/v1/convai/conversation` route. When only one of + // the two is set, it's used verbatim. + let base_url_override_owned = crate::cli_args::resolve_base_url_override( + root_matches, + &self.inner.name, + )?; + let composed = executor::compose_base_url_override( + base_url_override_owned.as_deref(), + self.inner.endpoint_url.as_deref(), + ); + let base_url_override = composed.as_deref(); + + let http_config = prepared.http_config.clone().with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(root_matches), + ); + + // Resolve binding-level CLI args (e.g. `--voice`, `--audio-out`) + // ONCE per dispatch, then pick the init payload and + // autoresponder via the dynamic-or-static helpers on `CliApp`. + // Channel-level `x-fern-init-payload` is still selected by the + // executor when both pickers return `None`. + let binding_args = self.inner.resolve_binding_args(root_matches); + let resolved_init_payload = self.inner.select_init_payload(&binding_args); + let resolved_autoresponder = self.inner.select_autoresponder(&binding_args); + + // --format http is HTTP-specific; reject for AsyncAPI/WebSocket. + // Use OutputPipeline::from_matches so both --format flag and + // _OUTPUT env var are resolved. + let pipeline = crate::formatter::OutputPipeline::from_matches( + root_matches, + &self.inner.name, + ) + .map_err(|e| CliError::Validation(e.to_string()))?; + if pipeline.is_http() { + return Err(CliError::Validation( + "the `http` output format is only supported for OpenAPI-based CLIs".to_string(), + )); + } + + executor::execute( + &prepared.doc, + channel_name, + channel, + message_arg, + ¶m_args, + base_url_override, + &self.inner.auth_bindings, + &http_config, + resolved_init_payload.as_ref(), + resolved_autoresponder, + dry_run, + executor::resolve_response_timeout(&self.inner.name), + ) + .await?; + + Ok(DispatchResult::Handled) + }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::binding::Binding; + use crate::openapi::OpenApiBinding; + + const FIXTURE: &str = include_str!("agent.asyncapi.yaml"); + + const TRIVIAL_OPENAPI: &str = r#"openapi: 3.0.0 +info: + title: Trivial + version: "1.0" +paths: + /ping: + get: + operationId: ping + x-fern-sdk-group-name: ["health"] + x-fern-sdk-method-name: ping + responses: + '200': + description: ok +"#; + + #[test] + fn binding_registers_alongside_openapi_without_panic() { + // Build both bindings and call build_command on each — both must succeed + // and the AsyncAPI tree must surface the fixture's AgentMessages leaf. + let mut async_binding = AsyncApiBinding::new().spec(FIXTURE); + async_binding.set_cli_name("convai"); + let async_cmd = async_binding + .build_command() + .expect("AsyncApiBinding must build a clap tree"); + // The ElevenLabs fixture's AgentMessages channel has no x-fern-sdk-method-name, + // so it falls back to kebab-case at the root. + let names: Vec<&str> = async_cmd.get_subcommands().map(|c| c.get_name()).collect(); + assert!( + names.contains(&"agent-messages"), + "expected agent-messages at the asyncapi root, got: {names:?}" + ); + + let mut openapi = OpenApiBinding::new().spec(TRIVIAL_OPENAPI); + openapi.set_cli_name("convai"); + let openapi_cmd = openapi + .build_command() + .expect("OpenApiBinding must build a clap tree"); + let openapi_names: Vec<&str> = openapi_cmd + .get_subcommands() + .map(|c| c.get_name()) + .collect(); + assert!( + openapi_names.contains(&"health"), + "expected health group from openapi spec, got: {openapi_names:?}" + ); + } + + #[test] + fn resolve_channel_agrees_with_registrar_on_empty_sdk_method_name() { + // Regression: an empty `x-fern-sdk-method-name` is treated as + // "missing" by the registrar (commands::leaf_command_name) — the + // resolver must agree, or dispatch would fail to match the + // kebab-fallback leaf the registrar actually created. + use super::super::discovery::Channel; + use std::collections::HashMap; + + let mut channels: HashMap = HashMap::new(); + channels.insert( + "AgentMessages".to_string(), + Channel { + sdk_method_name: Some(String::new()), + ..Channel::default() + }, + ); + + let doc = AsyncApiDescription { + channels, + ..Default::default() + }; + + // Build a minimal clap matches tree that lands on the kebab leaf. + let cli = clap::Command::new("root") + .subcommand_required(true) + .subcommand(clap::Command::new("agent-messages")); + let matches = cli.try_get_matches_from(["root", "agent-messages"]).unwrap(); + + let (resolved_name, _, _) = + AsyncApiBinding::resolve_channel(&doc, &matches).expect("resolver matches kebab leaf"); + assert_eq!(resolved_name, "AgentMessages"); + } + + #[test] + fn resolve_channel_disambiguates_shared_leaf_by_group_path() { + // Two channels share the leaf method name `list` but live under + // different groups. Leaf-only matching would silently pick whichever + // HashMap iteration yielded first; the resolver must compare the + // full subcommand path against each channel's `sdk_group_name`. + use super::super::discovery::Channel; + use std::collections::HashMap; + + let mut channels: HashMap = HashMap::new(); + channels.insert( + "AdminList".to_string(), + Channel { + sdk_group_name: vec!["admin".to_string()], + sdk_method_name: Some("list".to_string()), + ..Channel::default() + }, + ); + channels.insert( + "UserList".to_string(), + Channel { + sdk_group_name: vec!["users".to_string()], + sdk_method_name: Some("list".to_string()), + ..Channel::default() + }, + ); + + let doc = AsyncApiDescription { + channels, + ..Default::default() + }; + + // Mirror the registrar's nested-subcommand shape. + let cli = clap::Command::new("root") + .subcommand_required(true) + .subcommand( + clap::Command::new("admin") + .subcommand_required(true) + .subcommand(clap::Command::new("list")), + ) + .subcommand( + clap::Command::new("users") + .subcommand_required(true) + .subcommand(clap::Command::new("list")), + ); + + let admin_matches = cli + .clone() + .try_get_matches_from(["root", "admin", "list"]) + .unwrap(); + let (admin_name, _, _) = AsyncApiBinding::resolve_channel(&doc, &admin_matches) + .expect("admin list must resolve to AdminList"); + assert_eq!(admin_name, "AdminList"); + + let users_matches = cli + .try_get_matches_from(["root", "users", "list"]) + .unwrap(); + let (users_name, _, _) = AsyncApiBinding::resolve_channel(&doc, &users_matches) + .expect("users list must resolve to UserList"); + assert_eq!(users_name, "UserList"); + } + + #[test] + fn cli_arg_attaches_global_flag_to_built_clap_tree() { + // `--voice` registered on the binding must be reachable from any + // channel leaf because the arg is `.global(true)`. Use the + // ElevenLabs fixture which has `AgentMessages` falling back to + // the kebab leaf. + let mut binding = AsyncApiBinding::new() + .spec(FIXTURE) + .cli_arg("voice", BindingArgKind::Flag, "Enable voice mode"); + binding.set_cli_name("convai"); + let cmd = binding.build_command().expect("clap tree builds"); + // Pass `--voice` AFTER the leaf — only a global arg parses here. + let matches = cmd + .clone() + .try_get_matches_from(["convai", "agent-messages", "--voice"]) + .expect("--voice must parse on the leaf as a global"); + assert!( + matches + .try_get_one::("voice") + .ok() + .flatten() + .copied() + .unwrap_or(false), + "global --voice must surface on root matches", + ); + } + + #[test] + fn cli_arg_value_attaches_global_value_arg() { + let mut binding = AsyncApiBinding::new().spec(FIXTURE).cli_arg( + "audio-out", + BindingArgKind::Value, + "Capture PCM to file", + ); + binding.set_cli_name("convai"); + let cmd = binding.build_command().expect("clap tree builds"); + let matches = cmd + .clone() + .try_get_matches_from(["convai", "agent-messages", "--audio-out", "reply.pcm"]) + .expect("--audio-out must parse on the leaf as a global"); + assert_eq!( + matches + .try_get_one::("audio-out") + .ok() + .flatten() + .map(String::as_str), + Some("reply.pcm"), + ); + } + + #[test] + fn binding_inherits_root_auth() { + // set_root_auth should prepend root-level bindings so the + // composition with CliApp::auth + AsyncApiBinding works. + let mut binding = AsyncApiBinding::new().spec(FIXTURE); + let root = vec![( + "xi-api-key".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("XI_API_KEY")), + )]; + binding.set_root_auth(&root); + assert_eq!(binding.inner.auth_bindings.len(), 1); + } +} diff --git a/src/asyncapi/commands.rs b/src/asyncapi/commands.rs new file mode 100644 index 0000000..8b7bda7 --- /dev/null +++ b/src/asyncapi/commands.rs @@ -0,0 +1,534 @@ +//! AsyncAPI clap command emitter. +//! +//! Walks an [`AsyncApiDescription`] and produces a `clap::Command` tree +//! where each channel maps to a leaf subcommand. The hierarchy comes +//! from two Fern extensions on the channel object: +//! +//! - `x-fern-sdk-group-name: [a, b, c]` — nested subcommand groups +//! - `x-fern-sdk-method-name: leaf` — the leaf command name +//! +//! Channels without `x-fern-sdk-method-name` fall back to a kebab-case +//! form of the channel name (`AgentMessages` → `agent-messages`). +//! +//! Per-channel flags: +//! - `--message ` (optional) — single-shot client message +//! - one `--` per URL-template parameter declared under +//! `channels..parameters` +//! +//! Global flags (`--format`, `--base-url`, `--verbose`) are attached at +//! the root so the emitter is testable in isolation. CliApp integration +//! arrives in a later task. +//! +//! Self-contained — must not import from `crate::openapi` or +//! `crate::graphql`. See `AGENTS.md` ("Architecture: Code Generation +//! Model"). The `to_kebab_flag` / `sanitize_flag_name` helpers in +//! `crate::text` are shared infrastructure and are fair game. + +use std::collections::BTreeMap; + +use clap::{Arg, Command}; + +use crate::text::{sanitize_flag_name, to_kebab_flag}; + +use super::discovery::{AsyncApiDescription, Channel}; + +/// Build the full clap command tree for an AsyncAPI document. +/// +/// The root command carries the doc title (falling back to the version), +/// plus three global flags (`--format`, `--base-url`, `--verbose`) that +/// later wire-up phases can read from `CliApp`. Each channel becomes a +/// leaf subcommand nested under its `x-fern-sdk-group-name` path. +pub fn build_cli(doc: &AsyncApiDescription) -> Command { + let about_text = if doc.info.title.is_empty() { + format!("AsyncAPI CLI ({})", doc.asyncapi) + } else { + doc.info.title.clone() + }; + + let mut root = Command::new("asyncapi-cli") + .about(about_text) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + Arg::new("format") + .long("format") + .help("Output format: json (default), table, yaml, csv") + .value_name("FORMAT") + .global(true), + ) + .arg( + Arg::new("base-url") + .long("base-url") + .help("Override the WebSocket base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ) + .arg( + Arg::new("verbose") + .long("verbose") + .short('v') + .help("Enable verbose logging to stderr") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + // A pure-AsyncAPI CLI must own its own `--dry-run` global: when + // an `OpenApiBinding` is present in the same app it contributes + // this flag (and the merge dedups by id), but an AsyncAPI-only + // app has no other source for it. Without this arg the executor's + // dry-run gate is unreachable and `chat --dry-run` silently opens + // a live WebSocket. + Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ); + + // ---- Build the channel subtree ------------------------------------- + // Walk channels in sorted order so the emitted tree is deterministic + // regardless of the parser's HashMap iteration order. For each + // channel we walk down its group path (creating nodes as needed), + // then attach the leaf at the deepest level. + + let mut channel_names: Vec<&String> = doc.channels.keys().collect(); + channel_names.sort(); + + // Group-path nodes are tracked in a tree keyed by the full path so + // siblings under the same parent merge correctly. Once the tree is + // populated, we drain it into nested `Command` instances bottom-up. + let mut tree = GroupNode::default(); + for channel_name in channel_names { + let channel = &doc.channels[channel_name]; + let leaf_name = leaf_command_name(channel_name, channel); + let leaf_cmd = build_channel_command(&leaf_name, channel); + tree.insert(&channel.sdk_group_name, leaf_cmd); + } + + for child in tree.into_commands() { + root = root.subcommand(child); + } + + root +} + +/// Resolve the leaf clap command name for a channel. +/// +/// Prefers `x-fern-sdk-method-name` when set; otherwise falls back to +/// a kebab-case form of the AsyncAPI channel name. Empty strings are +/// treated as "missing" so a stray `x-fern-sdk-method-name: ""` doesn't +/// silently produce an unreachable empty subcommand. +pub(super) fn leaf_command_name(channel_name: &str, channel: &Channel) -> String { + match channel.sdk_method_name.as_deref() { + Some(name) if !name.is_empty() => name.to_string(), + _ => to_kebab_flag(channel_name), + } +} + +/// Build the leaf `clap::Command` for a single channel. +/// +/// Exposes `--message ` (optional, single-shot mode) plus one +/// `--` flag per declared URL-template parameter. Parameter +/// names are sorted before iteration so flag order is deterministic +/// across runs (HashMap iteration order is not stable). +fn build_channel_command(leaf_name: &str, channel: &Channel) -> Command { + let about = channel + .description + .clone() + .unwrap_or_else(|| format!("WebSocket channel `{leaf_name}`")); + + let mut cmd = Command::new(leaf_name.to_string()).about(about).arg( + Arg::new("message") + .long("message") + .help("Single-shot client message payload (text)") + .value_name("TEXT"), + ); + + let mut params: Vec<(&String, &super::discovery::ChannelParameter)> = + channel.parameters.iter().collect(); + params.sort_by(|a, b| a.0.cmp(b.0)); + for (param_name, param) in params { + // Sanitize the wire name (rejects whitespace / control chars) and + // get the kebab-cased flag spelling. Skip entries that refuse to + // sanitize — the emitter is infallible, so a malformed parameter + // name silently drops rather than panicking. + let Ok(long) = sanitize_flag_name(param_name) else { + continue; + }; + + let help = param + .description + .clone() + .unwrap_or_else(|| format!("URL template parameter `{param_name}`")); + + cmd = cmd.arg( + Arg::new(param_name.clone()) + .long(long) + .help(help) + .value_name("VALUE"), + ); + } + + cmd +} + +/// Intermediate tree node used to merge channels under shared +/// `sdk_group_name` paths before lowering into `clap::Command`s. +/// +/// `children` is a sorted map so the rendered subcommand order is +/// deterministic regardless of the input channel order. `leaves` +/// holds the commands that attach at this node directly (i.e. channels +/// whose group path ends at this depth). +#[derive(Default)] +struct GroupNode { + children: BTreeMap, + leaves: Vec, +} + +impl GroupNode { + /// Insert a leaf command at the end of `path`. Intermediate nodes + /// are created on demand so multiple channels can share group + /// prefixes (e.g. `["shared"]` for two distinct methods). + fn insert(&mut self, path: &[String], leaf: Command) { + match path.split_first() { + None => self.leaves.push(leaf), + Some((head, tail)) => { + self.children + .entry(head.clone()) + .or_default() + .insert(tail, leaf); + } + } + } + + /// Lower this node's children + leaves into a list of `Command`s + /// suitable for attaching as subcommands of the parent. Leaves come + /// first, then child groups in sorted order — both bands within + /// themselves are deterministic. + fn into_commands(self) -> Vec { + let GroupNode { children, leaves } = self; + let mut out: Vec = leaves; + for (name, child) in children { + let mut group_cmd = Command::new(name.clone()) + .about(format!("AsyncAPI group `{name}`")) + .subcommand_required(true) + .arg_required_else_help(true); + for sub in child.into_commands() { + group_cmd = group_cmd.subcommand(sub); + } + out.push(group_cmd); + } + out + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::asyncapi::parser::parse; + + /// Helper: parse a YAML fixture and build the clap tree. Panics on + /// parse failure since the fixtures are author-controlled. + fn build(spec: &str) -> Command { + let doc = parse(spec).expect("fixture should parse"); + build_cli(&doc) + } + + /// Find a (possibly nested) subcommand by walking a path of names. + fn descend<'a>(root: &'a Command, path: &[&str]) -> Option<&'a Command> { + let mut cur = root; + for segment in path { + cur = cur.find_subcommand(segment)?; + } + Some(cur) + } + + // ---------------------------------------------------------------- AC1 + + const SPEC_GROUP_METHOD: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com/{agent_id}" + protocol: wss +channels: + AgentMessages: + description: Bi-directional agent stream + x-fern-sdk-group-name: ["foo"] + x-fern-sdk-method-name: bar + parameters: + agent_id: + description: Agent ID + schema: + type: string +"#; + + #[test] + fn ac1_group_and_method_extension_produce_foo_bar_path() { + let cmd = build(SPEC_GROUP_METHOD); + // The path `foo bar` must be reachable via clap argument parsing. + let matches = cmd + .clone() + .try_get_matches_from([ + "bin", "foo", "bar", "--agent-id", "abc", + ]) + .expect("foo bar should parse"); + + // Drill down into the matched subcommand chain to confirm + // structure (not just an alias). + let (foo_name, foo_matches) = matches.subcommand().expect("foo subcmd present"); + assert_eq!(foo_name, "foo"); + let (bar_name, bar_matches) = foo_matches.subcommand().expect("bar subcmd present"); + assert_eq!(bar_name, "bar"); + assert_eq!( + bar_matches.get_one::("agent_id").map(String::as_str), + Some("abc"), + ); + } + + // ---------------------------------------------------------------- AC2 + + #[test] + fn ac2_leaf_exposes_optional_message_and_param_flags() { + let cmd = build(SPEC_GROUP_METHOD); + let bar = descend(&cmd, &["foo", "bar"]).expect("foo bar leaf present"); + + // --message exists, is long-only, and is optional. + let message_arg = bar + .get_arguments() + .find(|a| a.get_id() == "message") + .expect("--message arg missing"); + assert_eq!(message_arg.get_long(), Some("message")); + assert!( + !message_arg.is_required_set(), + "--message must be optional, but clap reports it required", + ); + + // --agent-id exists for the URL template param. + let agent_id = bar + .get_arguments() + .find(|a| a.get_id() == "agent_id") + .expect("--agent-id arg missing"); + assert_eq!(agent_id.get_long(), Some("agent-id")); + } + + #[test] + fn ac2_message_is_optional_parse_succeeds_without_it() { + let cmd = build(SPEC_GROUP_METHOD); + cmd.clone() + .try_get_matches_from(["bin", "foo", "bar", "--agent-id", "x"]) + .expect("leaf must parse without --message"); + } + + // ---------------------------------------------------------------- AC3 + + const SPEC_FALLBACK_KEBAB: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com" + protocol: wss +channels: + AgentMessages: + description: No SDK extensions +"#; + + #[test] + fn ac3_channel_without_method_name_falls_back_to_kebab_case() { + let cmd = build(SPEC_FALLBACK_KEBAB); + // `AgentMessages` → `agent-messages` at the root. + let matches = cmd + .clone() + .try_get_matches_from(["bin", "agent-messages"]) + .expect("kebab-case fallback should resolve"); + let (name, _) = matches.subcommand().expect("subcommand present"); + assert_eq!(name, "agent-messages"); + } + + // ----------------------------------------------------------- Nested + + const SPEC_NESTED_GROUPS: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com" + protocol: wss +channels: + Whatever: + x-fern-sdk-group-name: ["alpha", "beta"] + x-fern-sdk-method-name: gamma +"#; + + #[test] + fn nested_groups_resolve_three_levels_deep() { + let cmd = build(SPEC_NESTED_GROUPS); + cmd.clone() + .try_get_matches_from(["bin", "alpha", "beta", "gamma"]) + .expect("alpha beta gamma should resolve"); + } + + // ----------------------------------------------------------- Shared group + + const SPEC_SHARED_GROUP: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com" + protocol: wss +channels: + ChannelOne: + x-fern-sdk-group-name: ["shared"] + x-fern-sdk-method-name: first + ChannelTwo: + x-fern-sdk-group-name: ["shared"] + x-fern-sdk-method-name: second +"#; + + #[test] + fn shared_group_merges_two_distinct_leaves() { + let cmd = build(SPEC_SHARED_GROUP); + let shared = cmd + .find_subcommand("shared") + .expect("shared group present"); + assert!( + shared.find_subcommand("first").is_some(), + "first leaf must attach under shared", + ); + assert!( + shared.find_subcommand("second").is_some(), + "second leaf must attach under shared", + ); + + // And both must parse as full command lines. + cmd.clone() + .try_get_matches_from(["bin", "shared", "first"]) + .expect("shared first should parse"); + cmd.clone() + .try_get_matches_from(["bin", "shared", "second"]) + .expect("shared second should parse"); + } + + // ----------------------------------------------------------- Parameter kebab-casing + + const SPEC_PARAM_KEBAB: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com/{agent_id}/{clientSessionId}" + protocol: wss +channels: + Convai: + x-fern-sdk-method-name: convai + parameters: + agent_id: + schema: + type: string + clientSessionId: + schema: + type: string +"#; + + #[test] + fn parameter_flags_are_kebab_cased() { + let cmd = build(SPEC_PARAM_KEBAB); + let convai = cmd + .find_subcommand("convai") + .expect("convai leaf present"); + + let longs: Vec<&str> = convai + .get_arguments() + .filter_map(Arg::get_long) + .collect(); + assert!( + longs.contains(&"agent-id"), + "agent_id should become --agent-id; got: {longs:?}", + ); + assert!( + longs.contains(&"client-session-id"), + "clientSessionId should become --client-session-id; got: {longs:?}", + ); + } + + // ----------------------------------------------------------- No parameters + + const SPEC_NO_PARAMS: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com" + protocol: wss +channels: + Ping: + x-fern-sdk-method-name: ping +"#; + + #[test] + fn channel_without_params_exposes_only_message_flag() { + let cmd = build(SPEC_NO_PARAMS); + let ping = cmd.find_subcommand("ping").expect("ping leaf present"); + let arg_ids: Vec = ping + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!( + arg_ids.contains(&"message".to_string()), + "--message should always be present; got: {arg_ids:?}", + ); + // No other channel-specific args (clap's built-in --help is not + // returned by get_arguments). + let non_message: Vec<&String> = arg_ids + .iter() + .filter(|id| id.as_str() != "message") + .collect(); + assert!( + non_message.is_empty(), + "no extra args expected when channel has no parameters; got: {non_message:?}", + ); + } + + // ----------------------------------------------------------- Method-only / no group + + const SPEC_METHOD_ONLY: &str = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: "wss://example.com" + protocol: wss +channels: + AgentMessages: + x-fern-sdk-method-name: chat +"#; + + #[test] + fn method_without_group_attaches_at_root() { + let cmd = build(SPEC_METHOD_ONLY); + cmd.clone() + .try_get_matches_from(["bin", "chat"]) + .expect("chat should attach at root when no group is set"); + } +} diff --git a/src/asyncapi/discovery.rs b/src/asyncapi/discovery.rs new file mode 100644 index 0000000..591317f --- /dev/null +++ b/src/asyncapi/discovery.rs @@ -0,0 +1,141 @@ +//! AsyncAPI internal representation. +//! +//! Models the subset of AsyncAPI 2.6 used by the code generator — channels, +//! messages, operations, and servers. Payloads and schemas are kept as raw +//! `serde_json::Value` because this layer does not interpret JSON Schema. +//! +//! Like `src/openapi/discovery.rs` and `src/graphql/discovery.rs`, this +//! module is intentionally self-contained — it must not import from sibling +//! code-generation paths. + +use std::collections::HashMap; + +use serde::Deserialize; +use serde_json::Value; + +/// Top-level AsyncAPI document model. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct AsyncApiDescription { + /// AsyncAPI specification version (e.g. `"2.6.0"`). + pub asyncapi: String, + /// Document metadata. + #[serde(default)] + pub info: Info, + /// Servers keyed by server name. + #[serde(default)] + pub servers: HashMap, + /// Channels keyed by channel name. + #[serde(default)] + pub channels: HashMap, + /// Resolved message definitions keyed by component name. + /// + /// Populated from `components.messages.*` during parsing. + #[serde(default)] + pub messages: HashMap, + /// Component schemas keyed by component name, as raw JSON Schema values. + #[serde(default)] + pub schemas: HashMap, +} + +/// AsyncAPI `info` block. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Info { + #[serde(default)] + pub title: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub description: Option, +} + +/// AsyncAPI server entry (`servers.`). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Server { + #[serde(default)] + pub url: String, + #[serde(default)] + pub protocol: String, + #[serde(default)] + pub description: Option, +} + +/// AsyncAPI channel entry (`channels.`). +/// +/// `sdk_group_name` mirrors `x-fern-sdk-group-name` (a nested list of +/// strings) and `sdk_method_name` mirrors `x-fern-sdk-method-name` — +/// the same shape used by the OpenAPI path to drive `clap` command +/// hierarchies. Both default to empty / `None` when the extensions are +/// absent; the command emitter falls back to a kebab-case channel name +/// in that case. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Channel { + #[serde(default)] + pub description: Option, + #[serde(default)] + pub publish: Option, + #[serde(default)] + pub subscribe: Option, + #[serde(default)] + pub parameters: HashMap, + /// Nested subcommand group path from `x-fern-sdk-group-name`. + /// Empty when the extension is absent — the leaf attaches at root. + #[serde(default)] + pub sdk_group_name: Vec, + /// Leaf command name from `x-fern-sdk-method-name`. `None` when the + /// extension is absent — the emitter falls back to a kebab-case form + /// of the channel name. + #[serde(default)] + pub sdk_method_name: Option, + /// Raw JSON value of `x-fern-init-payload` — the frame that the + /// executor sends immediately after WebSocket connect. Typically + /// supplied by an overlay (ACP-3.1); `None` when the channel does + /// not declare an init payload. Preserved verbatim so nested objects + /// and arrays round-trip without lossy schema interpretation. + #[serde(default)] + pub x_fern_init_payload: Option, +} + +/// AsyncAPI operation (`publish` / `subscribe` under a channel). +/// +/// `message_refs` contains the bare component names extracted from +/// `#/components/messages/` `$ref` pointers — both single-ref and +/// `oneOf:` shapes are flattened here so consumers do not need to chase +/// pointers at parse time. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct Operation { + #[serde(default)] + pub operation_id: Option, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub description: Option, + /// Bare component message names referenced by this operation. + #[serde(default)] + pub message_refs: Vec, +} + +/// AsyncAPI message definition under `components.messages`. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct Message { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub title: Option, + #[serde(default)] + pub description: Option, + /// Raw payload definition (typically a JSON Schema or a `$ref`). + #[serde(default)] + pub payload: Value, +} + +/// AsyncAPI channel parameter (`channels..parameters.`). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ChannelParameter { + #[serde(default)] + pub description: Option, + /// Raw JSON Schema for the parameter. + #[serde(default)] + pub schema: Value, +} diff --git a/src/asyncapi/executor.rs b/src/asyncapi/executor.rs new file mode 100644 index 0000000..e93c1a9 --- /dev/null +++ b/src/asyncapi/executor.rs @@ -0,0 +1,1082 @@ +//! AsyncAPI executor — drives a `WebSocketClient` against a parsed +//! AsyncAPI channel. +//! +//! Public surface: [`execute`]. Three modes: +//! +//! 1. **Init** (always): send the channel's `x-fern-init-payload` (or a +//! builder-level explicit override) as the first WS frame. +//! 2. **Single-shot** (`--message `): emit one `UserMessage`-shaped +//! frame, await a single agent response, print its assembled text, then +//! Close(1000) and exit 0. +//! 3. **REPL** (no `--message`): forward stdin lines as outbound frames, +//! stream server responses to stdout, Close(1000) on EOF. +//! +//! Self-contained per the no-shared-abstractions rule +//! (`AGENTS.md` "Code Generation Model") — does NOT import from +//! `crate::openapi` or `crate::graphql`. Wire-level coverage lands in +//! ACP-2.4; this module's tests cover pure helpers only. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{json, Value}; + +use crate::auth::SchemeBinding; +use crate::error::CliError; +use crate::http::HttpConfig; +use crate::validate::encode_query_component; +use crate::websocket::{ResponderAction, WebSocketClient, WsAuth, WsConfig}; + +use super::discovery::{AsyncApiDescription, Channel}; + +// --------------------------------------------------------------------------- +// Pure helpers (testable in isolation) +// --------------------------------------------------------------------------- + +/// Compose a base URL override (`--base-url` / `_BASE_URL`) with +/// the binding's configured endpoint URL. +/// +/// Semantics: `--base-url` is a **host-only** override — it swaps the +/// scheme + authority (host:port), but preserves the path (and query, if +/// any) of the binding's configured endpoint. This matches the same +/// "host-only override" contract the OpenAPI path uses for HTTP base +/// URLs, and means a test running against `ws://127.0.0.1:` still +/// hits the binary's configured `/v1/convai/conversation` route. +/// +/// Returns: +/// - `Some(merged)` when both `override_url` and `endpoint_url` are set — +/// override's scheme+authority + endpoint's path-and-query. +/// - `Some(override_url)` when only `override_url` is set (no endpoint +/// path to preserve). +/// - `Some(endpoint_url)` when only `endpoint_url` is set. +/// - `None` when neither is set (caller falls back to the spec's +/// `servers` block). +/// +/// Parsing is intentionally lightweight — we split on the first `/` +/// after the `scheme://` prefix rather than depending on the `url` +/// crate. WS URLs in this codebase are `ws://host[:port][/path]` or +/// `wss://...`; the simple split handles the cases that matter without +/// pulling a new dependency. Malformed inputs (no `://`) fall back to +/// returning the override verbatim. +pub(crate) fn compose_base_url_override( + override_url: Option<&str>, + endpoint_url: Option<&str>, +) -> Option { + match (override_url, endpoint_url) { + (None, None) => None, + (Some(o), None) => Some(o.to_string()), + (None, Some(e)) => Some(e.to_string()), + (Some(o), Some(e)) => Some(merge_authority_with_path(o, e)), + } +} + +/// Take scheme+authority from `override_url`, path-and-query from +/// `endpoint_url`. If the override already carries a non-trivial path +/// (anything beyond `/`), the caller's intent is to use the override +/// verbatim — return it unchanged. If parsing fails on either side, +/// fall back to the override verbatim (the safer default — at worst the +/// test fails with a clear connect error rather than a silent path +/// rewrite). +fn merge_authority_with_path(override_url: &str, endpoint_url: &str) -> String { + let Some((o_scheme, o_rest)) = split_scheme(override_url) else { + return override_url.to_string(); + }; + // If the override carries its own path beyond `/`, honor it verbatim. + if let Some(slash_idx) = o_rest.find('/') { + let path = &o_rest[slash_idx..]; + if path != "/" && !path.is_empty() { + return override_url.to_string(); + } + } + let o_authority = o_rest.split('/').next().unwrap_or(o_rest); + + let Some((_, e_rest)) = split_scheme(endpoint_url) else { + return override_url.to_string(); + }; + let e_path = match e_rest.find('/') { + Some(idx) => &e_rest[idx..], + None => "", + }; + + format!("{o_scheme}://{o_authority}{e_path}") +} + +/// Split a URL at the `://` boundary. Returns `(scheme, rest)` — `rest` +/// includes everything after `://`. +fn split_scheme(url: &str) -> Option<(&str, &str)> { + url.find("://").map(|idx| (&url[..idx], &url[idx + 3..])) +} + +/// Build the WebSocket connect URL from a server URL, channel name, and +/// resolved channel parameters. +/// +/// Behavior: +/// - If `channel_name` starts with `/`, the channel name is appended to +/// `server_url` as a path. Otherwise the server URL is used as-is and +/// `channel_name` does not appear in the URL (the bare-name shape used +/// by the ElevenLabs fixture, where the channel name is a logical +/// identifier rather than a path). +/// - Parameters with non-empty values are appended as `?key=value&...` +/// with values percent-encoded via [`encode_query_component`]. Keys are +/// sorted alphabetically so the resulting URL is deterministic. +/// - Empty-valued parameters are skipped. +pub(crate) fn build_connect_url( + server_url: &str, + channel_name: &str, + params: &HashMap, +) -> String { + let mut url = if channel_name.starts_with('/') { + // Server URL may or may not end with `/`. Trim a trailing slash to + // avoid `wss://host//v1/foo`. + let base = server_url.trim_end_matches('/'); + format!("{base}{channel_name}") + } else { + server_url.to_string() + }; + + // Sort keys for determinism — HashMap iteration order is not stable. + let mut keys: Vec<&String> = params.keys().collect(); + keys.sort(); + let mut first = !url.contains('?'); + for key in keys { + let value = ¶ms[key]; + if value.is_empty() { + continue; + } + let sep = if first { '?' } else { '&' }; + first = false; + url.push(sep); + url.push_str(&encode_query_component(key)); + url.push('='); + url.push_str(&encode_query_component(value)); + } + url +} + +/// Build the outbound frame for a single-shot `--message ` request. +/// +/// Shape: `{"type": "user_message", "text": }`. This matches the +/// `UserMessage` event in the ElevenLabs convai AsyncAPI fixture; APIs +/// with a different client→server message shape will get a more +/// elaborate selector in a later task. +pub(crate) fn build_user_message_frame(text: &str) -> Value { + json!({ "type": "user_message", "text": text }) +} + +/// Concatenate the text from a sequence of inbound agent-response frames. +/// +/// Handles two shapes: +/// - `AgentChatResponsePart` — `.text_response_part.text`, one chunk per +/// frame. Chunks are concatenated in input order. +/// - `AgentResponse` — `.agent_response_event.agent_response`, a single +/// complete reply. +/// +/// Frames that match neither shape contribute nothing (the autoresponder +/// caller skips them). +pub(crate) fn concatenate_response_parts(frames: &[Value]) -> String { + let mut out = String::new(); + for frame in frames { + if let Some(chunk) = frame + .pointer("/text_response_part/text") + .and_then(Value::as_str) + { + out.push_str(chunk); + continue; + } + if let Some(full) = frame + .pointer("/agent_response_event/agent_response") + .and_then(Value::as_str) + { + out.push_str(full); + } + } + out +} + +/// Pick the init-payload value to send as the first WS frame. +/// +/// Builder-level explicit override wins; otherwise we fall back to the +/// channel's overlay-declared `x-fern-init-payload`. Returns `None` when +/// neither source set a payload — the executor skips the init send in +/// that case. +pub(crate) fn select_init_payload(channel: &Channel, explicit: Option<&Value>) -> Option { + if let Some(value) = explicit { + return Some(value.clone()); + } + channel.x_fern_init_payload.clone() +} + +/// Describe where a [`WsAuth`] would attach the credential, WITHOUT +/// resolving the secret value. +/// +/// Used by the `--dry-run` gate. We deliberately surface only the auth +/// *location + name*, never the resolved credential. This diverges from +/// the OpenAPI dry-run (which echoes the resolved header value): the WS +/// handshake can carry auth as a query parameter, and printing a resolved +/// secret into a URL on stdout is a worse leak than the header case. +/// Describing the location also means dry-run works with no credentials +/// configured at all. +pub(crate) fn describe_ws_auth(auth: &WsAuth) -> Value { + match auth { + WsAuth::QueryParam(name, _) => json!({ "location": "query_param", "name": name }), + WsAuth::Header(name, _) => json!({ "location": "header", "name": name }), + WsAuth::Headers(pairs) => { + let names: Vec<&String> = pairs.iter().map(|(n, _)| n).collect(); + json!({ "location": "headers", "names": names }) + } + WsAuth::FirstMessage(field, _) => json!({ "location": "first_message", "field": field }), + WsAuth::None => json!({ "location": "none" }), + } +} + +/// Build the `--dry-run` report for a WS channel: everything the executor +/// would do up to (but not including) opening the socket. Pure — no IO, no +/// network, no credential resolution — so it is unit-testable and safe to +/// run without any auth configured. +/// +/// Mirrors the intent of `openapi::executor`'s dry-run JSON: `dry_run: +/// true` plus the resolved request shape. The `mode` field reflects the +/// `--message` vs REPL branch the live path would have taken. +pub(crate) fn build_dry_run_info( + connect_url: &str, + channel_name: &str, + auth: &WsAuth, + init_payload: Option<&Value>, + message_arg: Option<&str>, +) -> Value { + json!({ + "dry_run": true, + "protocol": "websocket", + "url": connect_url, + "channel": channel_name, + "auth": describe_ws_auth(auth), + "init_payload": init_payload.cloned().unwrap_or(Value::Null), + "message_frame": message_arg.map(build_user_message_frame).unwrap_or(Value::Null), + "mode": if message_arg.is_some() { "single-shot" } else { "repl" }, + }) +} + +/// Default single-shot response timeout. A convai agent turn is normally +/// a few seconds; 30s leaves generous headroom while ensuring an agent +/// that never emits a recognized text response (e.g. one that only accepts +/// audio input) fails loudly instead of hanging the CLI forever. +pub(crate) const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30; + +/// Parse the single-shot response-timeout override value. Falls back to +/// [`DEFAULT_RESPONSE_TIMEOUT_SECS`] when the raw value is absent, empty, +/// unparseable, or zero — zero would mean "give up instantly", almost +/// always a misconfiguration rather than an intent. +pub(crate) fn parse_response_timeout(raw: Option<&str>) -> Duration { + raw.and_then(|s| s.trim().parse::().ok()) + .filter(|n| *n > 0) + .map(Duration::from_secs) + .unwrap_or_else(|| Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS)) +} + +/// Resolve the single-shot response timeout for a CLI, reading the +/// per-binary `_WS_RESPONSE_TIMEOUT_SECS` override (same uppercase / +/// hyphen→underscore prefix convention as the logging env vars). Env vars +/// are trusted user input (see `AGENTS.md`), so the value is parsed but +/// not path-validated. +pub(crate) fn resolve_response_timeout(cli_name: &str) -> Duration { + let prefix = cli_name.to_uppercase().replace('-', "_"); + let var = format!("{prefix}_WS_RESPONSE_TIMEOUT_SECS"); + parse_response_timeout(std::env::var(&var).ok().as_deref()) +} + +/// Build the error returned when single-shot mode waits out its response +/// timeout. `frames_seen` is the total inbound frame count — `0`/`1` points +/// at a silent agent (only the init-metadata frame arrived), a larger count +/// at a stream that never signalled turn-completion. +pub(crate) fn response_timeout_error(timeout: Duration, frames_seen: usize) -> CliError { + CliError::Other(anyhow::anyhow!( + "No agent response received within {}s ({frames_seen} inbound frame(s) seen, none \ + completed the turn). The agent may require audio input or have no text response \ + configured — try a text-capable agent, or raise the timeout via the \ + `_WS_RESPONSE_TIMEOUT_SECS` environment variable.", + timeout.as_secs(), + )) +} + +// --------------------------------------------------------------------------- +// Executor +// --------------------------------------------------------------------------- + +/// Resolve the WS server URL for this run. +/// +/// Precedence: +/// 1. `override_url` — typically `--base-url` or `_BASE_URL` (resolved +/// upstream in the binding), possibly composed with the binding's +/// `.endpoint(...)` path. Used verbatim if set. +/// 2. The single non-empty server URL declared in the spec. +/// +/// **Errors** with `CliError::Validation` when: +/// - no override is set AND the spec declares zero non-empty server URLs; +/// - no override is set AND the spec declares two or more non-empty server +/// URLs. AsyncAPI 2.6 keys servers by name, so picking one silently +/// (e.g. alphabetically) lets `development` win over `production`. +/// Bindings declaring multiple environments must disambiguate by +/// calling `.endpoint(...)` on the builder (or set `_BASE_URL` +/// at runtime) — the error message lists every declared candidate so +/// the caller can pick. +fn resolve_server_url(doc: &AsyncApiDescription, override_url: Option<&str>) -> Result { + if let Some(url) = override_url { + return Ok(url.to_string()); + } + // Sort for deterministic, reproducible error output. AsyncAPI 2.6 + // keys servers by name; HashMap iteration order is not stable. + let mut named: Vec<(&String, &super::discovery::Server)> = doc + .servers + .iter() + .filter(|(_, s)| !s.url.is_empty()) + .collect(); + named.sort_by(|a, b| a.0.cmp(b.0)); + match named.as_slice() { + [] => Err(CliError::Validation( + "AsyncAPI document declares no server URL and no base-url override was supplied".into(), + )), + [(_, server)] => Ok(server.url.clone()), + many => { + let candidates = many + .iter() + .map(|(name, server)| format!("`{name}` ({})", server.url)) + .collect::>() + .join(", "); + Err(CliError::Validation(format!( + "AsyncAPI document declares {} server URLs; cannot pick one \ + unambiguously. Disambiguate by calling `.endpoint()` on \ + the binding (or set `_BASE_URL` / pass `--base-url`). \ + Declared servers: {candidates}", + many.len(), + ))) + } + } +} + +/// Resolve the auth source for the WS connect handshake. +/// +/// For v1 we wire only the single-scheme case: when one auth binding is +/// registered and it's a `Token` source, attach it as a header whose name +/// is the binding's scheme name (e.g. `xi-api-key` for ElevenLabs convai, +/// `Authorization` for a bearer-style scheme). Unbound / multi-binding / +/// non-token cases fall through to `WsAuth::None`; downstream auth +/// validation belongs to `validate_auth` on the binding. +/// +/// When more than one auth binding is registered (e.g. a future binary +/// composing root-level `.auth(...)` with a binding-level `auth_scheme_env` +/// via `set_root_auth`), the WS handshake silently connects unauthenticated +/// — emit a `tracing::warn!` so the misconfiguration shows up in +/// `_LOG` instead of as an opaque server-side 401. +fn resolve_ws_auth(auth_bindings: &[(String, SchemeBinding)]) -> WsAuth { + if auth_bindings.len() == 1 { + if let (scheme_name, SchemeBinding::Token(source)) = &auth_bindings[0] { + return WsAuth::Header(scheme_name.clone(), source.clone()); + } + } + if auth_bindings.len() > 1 { + let schemes: Vec<&str> = auth_bindings.iter().map(|(name, _)| name.as_str()).collect(); + tracing::warn!( + schemes = ?schemes, + "resolve_ws_auth: multiple auth bindings registered (only single-scheme is wired in v1); \ + WebSocket connect will fall back to unauthenticated. Bind exactly one Token scheme on \ + the AsyncAPI binding.", + ); + } + WsAuth::None +} + +/// Drive a single AsyncAPI channel: connect, send init payload, then +/// either run single-shot (`--message `) or REPL mode. +#[allow(clippy::too_many_arguments)] +pub async fn execute( + doc: &AsyncApiDescription, + channel_name: &str, + channel: &Channel, + message_arg: Option<&str>, + param_args: &HashMap, + base_url_override: Option<&str>, + auth_bindings: &[(String, SchemeBinding)], + http_config: &HttpConfig, + explicit_init_payload: Option<&Value>, + auto_responder: Option, + dry_run: bool, + response_timeout: Duration, +) -> Result<(), CliError> { + let server_url = resolve_server_url(doc, base_url_override)?; + let connect_url = build_connect_url(&server_url, channel_name, param_args); + + let mut ws_config = WsConfig::new(connect_url); + ws_config.auth = resolve_ws_auth(auth_bindings); + + // ---- Mode selection ------------------------------------------------- + let init_payload = select_init_payload(channel, explicit_init_payload); + + // `--dry-run`: report the connection we WOULD open and return before + // touching the network. Without this gate the flag is silently ignored + // and a live WebSocket is opened against production. Mirrors the + // OpenAPI executor's dry-run short-circuit. + if dry_run { + let info = build_dry_run_info( + &ws_config.url, + channel_name, + &ws_config.auth, + init_payload.as_ref(), + message_arg, + ); + let rendered = serde_json::to_string_pretty(&info).map_err(|e| { + CliError::Other(anyhow::anyhow!("failed to render dry-run output: {e}")) + })?; + println!("{rendered}"); + return Ok(()); + } + + if let Some(text) = message_arg { + // The single-shot path installs its own assembly closure (convai + // turn-completion shape); the binding-provided autoresponder is + // ignored here pending a generic single-shot model. See run_repl + // for the bidirectional path that honors it. + run_single_shot(ws_config, http_config, init_payload, text, response_timeout).await + } else { + // REPL mode is interactive and stdin-driven — it intentionally has + // no response timeout (the user, not an agent turn, drives the loop). + run_repl(ws_config, http_config, init_payload, auto_responder).await + } +} + +/// Mode 2: send `init payload (if any) + UserMessage`, await one agent +/// response, print, Close(1000), exit 0. +async fn run_single_shot( + mut ws_config: WsConfig, + http_config: &HttpConfig, + init_payload: Option, + message_text: &str, + response_timeout: Duration, +) -> Result<(), CliError> { + // Capture multi-part assembly state. The autoresponder closure flushes + // on a `stop`-typed part OR on the first `AgentResponse` (single-frame + // shape) — whichever arrives first signals "turn complete". + let parts: Arc>> = Arc::new(Mutex::new(Vec::new())); + let done: Arc> = Arc::new(Mutex::new(false)); + // Total inbound frames, for the response-timeout diagnostic. + let seen: Arc = Arc::new(AtomicUsize::new(0)); + + let parts_inner = Arc::clone(&parts); + let done_inner = Arc::clone(&done); + let seen_inner = Arc::clone(&seen); + + // Autoresponder responsibilities, end to end: + // - reply with a `pong` to `ping` frames (the server times us out + // at 20s without one), + // - capture `agent_chat_response_part` / `agent_response` frames + // into `parts` and elide their raw emit, + // - mark `done = true` once the turn completes so the recv loop + // can shut down and the caller can print the assembled reply. + // + // The closure must be `Fn`, hence the Mutex. Suppression for + // captured frames uses [`ResponderAction::Suppress`] — no bytes are + // written to the wire for elision, unlike the earlier `{}` ack hack. + let responder: crate::websocket::AutoResponder = Arc::new(move |frame: &Value| { + // Count every inbound frame so a response timeout can report how + // much (if anything) the agent sent before giving up. + seen_inner.fetch_add(1, Ordering::Relaxed); + + // Ping/pong is the only frame shape we actively reply to (otherwise + // the server times us out at 20s). + if frame.get("type").and_then(Value::as_str) == Some("ping") { + if let Some(event_id) = frame + .pointer("/ping_event/event_id") + .and_then(Value::as_i64) + { + return Some(ResponderAction::Reply( + json!({"type": "pong", "event_id": event_id}), + )); + } + } + + // Capture agent response shapes for later assembly. + let ty = frame.get("type").and_then(Value::as_str); + let is_part = ty == Some("agent_chat_response_part"); + let is_full = ty == Some("agent_response"); + if is_part || is_full { + let mut guard = parts_inner.lock().unwrap(); + guard.push(frame.clone()); + // Heuristic: AgentResponse is single-frame → turn complete. + // AgentChatResponsePart with `.text_response_part.is_final == true` + // OR a `.text_response_part.type == "stop"` marks the end of a + // streaming turn. The fixture spec does not lock the shape down, + // so we accept either signal. + let final_part = frame + .pointer("/text_response_part/is_final") + .and_then(Value::as_bool) + .unwrap_or(false); + let stop_part = frame + .pointer("/text_response_part/type") + .and_then(Value::as_str) + == Some("stop"); + if is_full || final_part || stop_part { + *done_inner.lock().unwrap() = true; + } + // Elide raw emit; the assembled reply prints once the turn + // completes (see below). No bytes go to the wire. + return Some(ResponderAction::Suppress); + } + None + }); + ws_config.auto_responder = Some(responder); + + let mut client = WebSocketClient::connect(ws_config, http_config).await?; + + // Send init payload first if declared. + if let Some(ref payload) = init_payload { + client.send(payload).await?; + } + + // Then the user's message. + let frame = build_user_message_frame(message_text); + client.send(&frame).await?; + + // Spin the recv loop until the autoresponder marks the turn done. + // We use a oneshot shutdown future driven off the `done` flag. + let done_check = Arc::clone(&done); + let shutdown = Box::pin(async move { + loop { + if *done_check.lock().unwrap() { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + }); + + // Bound the wait: without this, an agent that never emits a recognized + // text response (e.g. one that only accepts audio input) leaves the + // `done` flag false forever and the CLI hangs indefinitely. On timeout + // we fail loudly with a diagnostic rather than blocking. `run_recv_loop` + // (REPL) is deliberately exempt — it's interactive. + match tokio::time::timeout(response_timeout, client.run_until_shutdown(shutdown)).await { + Ok(result) => result?, + Err(_elapsed) => { + return Err(response_timeout_error( + response_timeout, + seen.load(Ordering::Relaxed), + )); + } + } + + // Flush the assembled response to stdout as a single line. + let assembled = { + let guard = parts.lock().unwrap(); + concatenate_response_parts(&guard) + }; + if !assembled.is_empty() { + println!("{assembled}"); + } + Ok(()) +} + +/// Mode 3: stdin → outbound frames; server frames → stdout. EOF on stdin +/// sends Close(1000) and exits 0. +async fn run_repl( + mut ws_config: WsConfig, + http_config: &HttpConfig, + init_payload: Option, + auto_responder: Option, +) -> Result<(), CliError> { + ws_config.stdin_input = true; + // Stdin lines are NOT necessarily JSON in REPL mode — accept anything + // and let the server reject malformed input. JSON validation would + // surprise users with a friendly REPL. + ws_config.stdin_validate_json = false; + // Application-level keepalive is API-specific; the binding-provided + // autoresponder ships from the customer's binary (e.g. convai + // ping/pong) instead of being baked into the framework. + ws_config.auto_responder = auto_responder; + + let mut client = WebSocketClient::connect(ws_config, http_config).await?; + + if let Some(payload) = init_payload { + client.send(&payload).await?; + } + + client.run_recv_loop().await +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::asyncapi::discovery::Channel; + + fn channel_with_init(payload: Option) -> Channel { + Channel { + x_fern_init_payload: payload, + ..Channel::default() + } + } + + // -- select_init_payload -------------------------------------------------- + + #[test] + fn init_payload_uses_overlay_when_set() { + let payload = json!({ + "type": "conversation_initiation_client_data", + "conversation_config_override": { + "agent": {"language": "en"}, + "tts": {"voice_id": "voice-x"} + } + }); + let channel = channel_with_init(Some(payload.clone())); + let selected = select_init_payload(&channel, None).expect("Some"); + // Verbatim — nested keys preserved. + assert_eq!(selected, payload); + } + + #[test] + fn init_payload_explicit_overrides_overlay() { + let overlay = json!({"type": "overlay"}); + let explicit = json!({"type": "explicit", "extra": 42}); + let channel = channel_with_init(Some(overlay)); + let selected = select_init_payload(&channel, Some(&explicit)).expect("Some"); + assert_eq!(selected, explicit); + } + + #[test] + fn init_payload_none_when_neither_set() { + let channel = channel_with_init(None); + assert!(select_init_payload(&channel, None).is_none()); + } + + // -- build_connect_url ---------------------------------------------------- + + #[test] + fn connect_url_encodes_special_chars() { + let mut params = HashMap::new(); + params.insert("agent_id".to_string(), "bad/id?foo&bar".to_string()); + let url = build_connect_url("wss://api.elevenlabs.io", "AgentMessages", ¶ms); + assert!( + url.contains("agent_id=bad%2Fid%3Ffoo%26bar"), + "expected encoded agent_id, got: {url}", + ); + // EXACTLY ONE `?` — no extra query params leaked. + assert_eq!( + url.matches('?').count(), + 1, + "url must have exactly one `?` separator, got: {url}", + ); + } + + #[test] + fn connect_url_sorts_params() { + let mut params = HashMap::new(); + params.insert("zeta".to_string(), "z".to_string()); + params.insert("alpha".to_string(), "a".to_string()); + params.insert("mid".to_string(), "m".to_string()); + let url = build_connect_url("wss://example.com", "AgentMessages", ¶ms); + // Find the indices of each key — alphabetical order required. + let i_alpha = url.find("alpha=").expect("alpha present"); + let i_mid = url.find("mid=").expect("mid present"); + let i_zeta = url.find("zeta=").expect("zeta present"); + assert!(i_alpha < i_mid, "alpha must come before mid in {url}"); + assert!(i_mid < i_zeta, "mid must come before zeta in {url}"); + } + + #[test] + fn connect_url_appends_channel_path_when_starts_with_slash() { + let params = HashMap::new(); + let url = build_connect_url("wss://api.elevenlabs.io", "/v1/convai/conversation", ¶ms); + assert_eq!(url, "wss://api.elevenlabs.io/v1/convai/conversation"); + } + + #[test] + fn connect_url_skips_empty_value_params() { + let mut params = HashMap::new(); + params.insert("agent_id".to_string(), "abc".to_string()); + params.insert("nothing".to_string(), "".to_string()); + let url = build_connect_url("wss://example.com", "AgentMessages", ¶ms); + assert!(url.contains("agent_id=abc")); + assert!(!url.contains("nothing="), "empty-valued params must be skipped: {url}"); + } + + // -- build_user_message_frame -------------------------------------------- + + #[test] + fn user_message_frame_shape() { + let frame = build_user_message_frame("hi"); + assert_eq!(frame, json!({"type": "user_message", "text": "hi"})); + } + + // -- concatenate_response_parts ----------------------------------------- + + #[test] + fn concatenate_parts_assembles_chunks() { + let frames = vec![ + json!({ + "type": "agent_chat_response_part", + "text_response_part": {"text": "Hel"} + }), + json!({ + "type": "agent_chat_response_part", + "text_response_part": {"text": "lo, "} + }), + json!({ + "type": "agent_chat_response_part", + "text_response_part": {"text": "world"} + }), + ]; + assert_eq!(concatenate_response_parts(&frames), "Hello, world"); + } + + #[test] + fn concatenate_parts_handles_agent_response() { + let frames = vec![json!({ + "type": "agent_response", + "agent_response_event": {"agent_response": "complete reply"} + })]; + assert_eq!(concatenate_response_parts(&frames), "complete reply"); + } + + #[test] + fn concatenate_parts_ignores_unknown_shapes() { + let frames = vec![ + json!({"type": "vad_score", "vad_score_event": {"vad_score": 0.5}}), + json!({"type": "ping", "ping_event": {"event_id": 7}}), + ]; + assert_eq!(concatenate_response_parts(&frames), ""); + } + + // -- resolve_ws_auth ----------------------------------------------------- + + #[test] + fn resolve_ws_auth_uses_scheme_name_from_binding() { + // A non-xi-api-key scheme name must propagate to the WS header name, + // not be silently rewritten. Regression for a copy-paste hardcoded + // `"xi-api-key"` header. + use crate::auth::AuthCredentialSource; + + let bindings = vec![( + "Authorization".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("MY_TOKEN")), + )]; + match resolve_ws_auth(&bindings) { + WsAuth::Header(name, _) => assert_eq!(name, "Authorization"), + _ => panic!("expected WsAuth::Header variant"), + } + } + + #[test] + fn resolve_ws_auth_preserves_xi_api_key_scheme() { + // ElevenLabs-shaped binding must still produce an `xi-api-key` + // header — no regression in the canonical case. + use crate::auth::AuthCredentialSource; + + let bindings = vec![( + "xi-api-key".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("XI_API_KEY")), + )]; + match resolve_ws_auth(&bindings) { + WsAuth::Header(name, _) => assert_eq!(name, "xi-api-key"), + _ => panic!("expected WsAuth::Header variant"), + } + } + + #[test] + fn resolve_ws_auth_falls_back_to_none_with_multiple_bindings() { + // When more than one auth binding is registered (e.g. root-level + // `.auth(...)` composed with a binding-level `auth_scheme_env` via + // `set_root_auth`), the single-scheme code path is skipped and + // `WsAuth::None` is returned. The accompanying `tracing::warn!` + // surfaces the misconfiguration in `_LOG`; this test just + // pins the structural fallback so a future refactor doesn't + // silently start picking the first / last binding. + use crate::auth::AuthCredentialSource; + + let bindings = vec![ + ( + "Authorization".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("ROOT_TOKEN")), + ), + ( + "xi-api-key".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("XI_API_KEY")), + ), + ]; + assert!(matches!(resolve_ws_auth(&bindings), WsAuth::None)); + } + + #[test] + fn resolve_ws_auth_falls_back_to_none_with_no_bindings() { + // Zero bindings is a legitimate "unauthenticated WS" case (no creds + // configured at all) — no warn, just `WsAuth::None`. + assert!(matches!(resolve_ws_auth(&[]), WsAuth::None)); + } + + // -- compose_base_url_override -------------------------------------------- + + #[test] + fn compose_base_url_none_when_both_unset() { + assert!(compose_base_url_override(None, None).is_none()); + } + + #[test] + fn compose_base_url_uses_override_alone() { + let out = compose_base_url_override(Some("ws://127.0.0.1:1234"), None); + assert_eq!(out.as_deref(), Some("ws://127.0.0.1:1234")); + } + + #[test] + fn compose_base_url_uses_endpoint_alone() { + let out = compose_base_url_override(None, Some("wss://api.elevenlabs.io/v1/convai/conversation")); + assert_eq!( + out.as_deref(), + Some("wss://api.elevenlabs.io/v1/convai/conversation"), + ); + } + + #[test] + fn compose_base_url_merges_authority_with_endpoint_path() { + // The canonical wire-test shape: host swap, path preserved. + let out = compose_base_url_override( + Some("ws://127.0.0.1:1234"), + Some("wss://api.elevenlabs.io/v1/convai/conversation"), + ); + assert_eq!( + out.as_deref(), + Some("ws://127.0.0.1:1234/v1/convai/conversation"), + ); + } + + #[test] + fn compose_base_url_override_with_explicit_path_wins() { + // If the override carries its own non-trivial path, honor it verbatim — + // the user told us exactly where to connect. + let out = compose_base_url_override( + Some("ws://127.0.0.1:1234/custom/path"), + Some("wss://api.elevenlabs.io/v1/convai/conversation"), + ); + assert_eq!(out.as_deref(), Some("ws://127.0.0.1:1234/custom/path")); + } + + #[test] + fn compose_base_url_trailing_slash_override_still_takes_endpoint_path() { + // `ws://host/` is treated as "no path beyond root" — endpoint path wins. + let out = compose_base_url_override( + Some("ws://127.0.0.1:1234/"), + Some("wss://api.elevenlabs.io/v1/convai/conversation"), + ); + assert_eq!( + out.as_deref(), + Some("ws://127.0.0.1:1234/v1/convai/conversation"), + ); + } + + // -- resolve_server_url -------------------------------------------------- + + fn doc_with_servers(entries: &[(&str, &str)]) -> AsyncApiDescription { + let mut servers = HashMap::new(); + for (name, url) in entries { + servers.insert( + (*name).to_string(), + super::super::discovery::Server { + url: (*url).to_string(), + ..Default::default() + }, + ); + } + AsyncApiDescription { servers, ..Default::default() } + } + + #[test] + fn resolve_server_url_override_always_wins() { + // Even when the spec declares multiple servers, an explicit override + // (`--base-url` / `.endpoint()`) is used verbatim — no ambiguity error. + let doc = doc_with_servers(&[ + ("production", "wss://api.example.com"), + ("development", "wss://dev.example.com"), + ]); + let url = resolve_server_url(&doc, Some("ws://127.0.0.1:1234")).expect("ok"); + assert_eq!(url, "ws://127.0.0.1:1234"); + } + + #[test] + fn resolve_server_url_single_server_used() { + let doc = doc_with_servers(&[("production", "wss://api.example.com")]); + let url = resolve_server_url(&doc, None).expect("ok"); + assert_eq!(url, "wss://api.example.com"); + } + + #[test] + fn resolve_server_url_empty_url_skipped() { + // An entry with an empty URL is treated as not-present; the one + // non-empty entry is unambiguous. + let doc = doc_with_servers(&[ + ("production", "wss://api.example.com"), + ("placeholder", ""), + ]); + let url = resolve_server_url(&doc, None).expect("ok"); + assert_eq!(url, "wss://api.example.com"); + } + + #[test] + fn resolve_server_url_zero_servers_errors() { + let doc = doc_with_servers(&[]); + let err = resolve_server_url(&doc, None).expect_err("must error"); + match err { + CliError::Validation(msg) => assert!( + msg.contains("declares no server URL"), + "expected no-server-URL message, got: {msg}", + ), + other => panic!("expected Validation, got {other:?}"), + } + } + + #[test] + fn resolve_server_url_multiple_servers_errors_with_candidates() { + // Regression for the silent alphabetical-pick footgun: when the spec + // declares two non-empty server URLs and no override is set, the + // resolver MUST error rather than picking one. The error must list + // every declared candidate so the caller can disambiguate. + let doc = doc_with_servers(&[ + ("development", "wss://dev.example.com"), + ("production", "wss://api.example.com"), + ]); + let err = resolve_server_url(&doc, None).expect_err("must error"); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("declares 2 server URLs"), + "expected count in error, got: {msg}", + ); + assert!( + msg.contains("`production`") && msg.contains("`development`"), + "error must list both server names, got: {msg}", + ); + assert!( + msg.contains(".endpoint("), + "error must point at the .endpoint() remediation, got: {msg}", + ); + } + other => panic!("expected Validation, got {other:?}"), + } + } + + // -- describe_ws_auth ----------------------------------------------------- + + #[test] + fn describe_ws_auth_surfaces_location_and_name_without_secret() { + use crate::auth::AuthCredentialSource; + + // Header auth → location + header name, never the resolved value. + let header = WsAuth::Header( + "xi-api-key".into(), + AuthCredentialSource::literal("super-secret-key"), + ); + let desc = describe_ws_auth(&header); + assert_eq!(desc, json!({ "location": "header", "name": "xi-api-key" })); + // The secret must not leak into the description anywhere. + assert!(!desc.to_string().contains("super-secret-key")); + } + + #[test] + fn describe_ws_auth_covers_every_variant() { + use crate::auth::AuthCredentialSource; + let src = || AuthCredentialSource::literal("x"); + + assert_eq!( + describe_ws_auth(&WsAuth::QueryParam("authorization".into(), src())), + json!({ "location": "query_param", "name": "authorization" }), + ); + assert_eq!( + describe_ws_auth(&WsAuth::Headers(vec![ + ("Authorization".into(), src()), + ("OpenAI-Beta".into(), src()), + ])), + json!({ "location": "headers", "names": ["Authorization", "OpenAI-Beta"] }), + ); + assert_eq!( + describe_ws_auth(&WsAuth::FirstMessage("xi_api_key".into(), src())), + json!({ "location": "first_message", "field": "xi_api_key" }), + ); + assert_eq!( + describe_ws_auth(&WsAuth::None), + json!({ "location": "none" }), + ); + } + + // -- build_dry_run_info --------------------------------------------------- + + #[test] + fn dry_run_info_single_shot_shape() { + let init = json!({ "type": "conversation_initiation_client_data" }); + let info = build_dry_run_info( + "wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agt_1", + "conversation", + &WsAuth::Header("xi-api-key".into(), crate::auth::AuthCredentialSource::literal("k")), + Some(&init), + Some("hello"), + ); + assert_eq!(info["dry_run"], json!(true)); + assert_eq!(info["protocol"], json!("websocket")); + assert_eq!( + info["url"], + json!("wss://api.elevenlabs.io/v1/convai/conversation?agent_id=agt_1") + ); + assert_eq!(info["channel"], json!("conversation")); + assert_eq!(info["mode"], json!("single-shot")); + assert_eq!(info["init_payload"], init); + assert_eq!( + info["message_frame"], + json!({ "type": "user_message", "text": "hello" }) + ); + } + + // -- parse_response_timeout ----------------------------------------------- + + #[test] + fn response_timeout_parses_valid_override() { + assert_eq!(parse_response_timeout(Some("5")), Duration::from_secs(5)); + assert_eq!(parse_response_timeout(Some(" 12 ")), Duration::from_secs(12)); + } + + #[test] + fn response_timeout_falls_back_on_bad_or_zero_values() { + let default = Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS); + assert_eq!(parse_response_timeout(None), default); + assert_eq!(parse_response_timeout(Some("")), default); + assert_eq!(parse_response_timeout(Some("nope")), default); + // Zero would mean "give up instantly" — treat as misconfig. + assert_eq!(parse_response_timeout(Some("0")), default); + // Negative parses as invalid u64 → default. + assert_eq!(parse_response_timeout(Some("-3")), default); + } + + #[test] + fn response_timeout_error_mentions_duration_frames_and_override() { + let err = response_timeout_error(Duration::from_secs(7), 3); + let msg = err.to_string(); + assert!(msg.contains("within 7s"), "got: {msg}"); + assert!(msg.contains("3 inbound frame"), "got: {msg}"); + assert!( + msg.contains("WS_RESPONSE_TIMEOUT_SECS"), + "error should point at the override env var, got: {msg}" + ); + } + + #[test] + fn dry_run_info_repl_has_null_message_and_init() { + let info = build_dry_run_info( + "wss://example.com/chan", + "chan", + &WsAuth::None, + None, + None, + ); + assert_eq!(info["mode"], json!("repl")); + assert_eq!(info["message_frame"], Value::Null); + assert_eq!(info["init_payload"], Value::Null); + assert_eq!(info["auth"], json!({ "location": "none" })); + } +} diff --git a/src/asyncapi/mod.rs b/src/asyncapi/mod.rs new file mode 100644 index 0000000..7f9b144 --- /dev/null +++ b/src/asyncapi/mod.rs @@ -0,0 +1,27 @@ +//! AsyncAPI code-generation path. +//! +//! Parses AsyncAPI 2.6 documents (WebSocket protocol only) and exposes the +//! internal model used to drive code generation. The shape of this module +//! mirrors `src/openapi/` and `src/graphql/`, but is intentionally +//! self-contained — no abstractions are shared across the three paths. +//! See `AGENTS.md` ("Architecture: Code Generation Model") for the +//! no-shared-abstractions rule. + +pub mod app; +pub mod binding; +pub mod commands; +pub mod discovery; +pub mod executor; +pub mod overlay; +pub mod parser; + +pub use app::{BindingArgKind, BindingArgs, CliApp}; +pub use binding::AsyncApiBinding; +pub use discovery::{ + AsyncApiDescription, Channel, ChannelParameter, Info, Message, Operation, Server, +}; +pub use overlay::{ + apply_overlay, apply_overlays_to_spec, parse_overlay, validate_overlay, OverlayAction, + OverlayDocument, OverlayInfo, +}; +pub use parser::parse; diff --git a/src/asyncapi/overlay.rs b/src/asyncapi/overlay.rs new file mode 100644 index 0000000..d6984fc --- /dev/null +++ b/src/asyncapi/overlay.rs @@ -0,0 +1,1924 @@ +//! AsyncAPI Overlay support (v1.0.0). +//! +//! Applies overlays (modeled on the [OpenAPI Overlays +//! specification](https://spec.openapis.org/overlay/latest.html)) to an +//! AsyncAPI document represented as a generic JSON value. Each overlay +//! contains a list of *actions* whose `target` is a JSONPath (RFC 9535) +//! expression. Actions either **update** (deep-merge) or **remove** +//! matched nodes. +//! +//! Per the no-shared-abstractions rule (see `AGENTS.md`), this module is a +//! direct duplicate of `src/openapi/overlay.rs`. The semantics — JSONPath +//! resolution, deep-merge, array-append, root targeting, sequential +//! application — are identical; only the surrounding context (AsyncAPI +//! instead of OpenAPI) differs. + +use serde::Deserialize; +use serde_json::Value; +use serde_json_path::JsonPath; + +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Overlay document types +// --------------------------------------------------------------------------- + +/// A single overlay action targeting nodes via a JSONPath expression. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayAction { + /// JSONPath (RFC 9535) expression selecting target nodes. + pub target: String, + /// Human-readable description of the action. + #[serde(default)] + pub description: Option, + /// Value to deep-merge into each matched node. Required when `remove` is + /// false/absent. + #[serde(default)] + pub update: Option, + /// When `true`, matched nodes are removed instead of updated. + #[serde(default)] + pub remove: bool, +} + +/// Metadata block inside an overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayInfo { + pub title: String, + pub version: String, +} + +/// A complete overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayDocument { + /// Overlay specification version (e.g. `"1.0.0"`). + pub overlay: String, + /// Metadata about this overlay. + pub info: OverlayInfo, + /// Optional base document this overlay extends. + #[serde(default)] + pub extends: Option, + /// Ordered list of actions to apply. + pub actions: Vec, +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse an overlay document from a YAML or JSON string. +pub fn parse_overlay(input: &str) -> Result { + // Try JSON first, then YAML + serde_json::from_str::(input) + .or_else(|_| { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(input) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}")))?; + let json_value = yaml_to_json(yaml_value); + serde_json::from_value::(json_value) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}"))) + }) + .map_err(|e| match e { + CliError::Discovery(_) => e, + _ => CliError::Discovery(format!("Failed to parse overlay file: {e}")), + }) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate the structure of a parsed overlay document. +pub fn validate_overlay(overlay: &OverlayDocument) -> Result<(), CliError> { + if overlay.overlay.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'overlay' version field".to_string(), + )); + } + + if overlay.info.title.is_empty() || overlay.info.version.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'info.title' or 'info.version' field".to_string(), + )); + } + + if overlay.actions.is_empty() { + return Err(CliError::Validation( + "Overlay file must have at least one action".to_string(), + )); + } + + for (i, action) in overlay.actions.iter().enumerate() { + if action.target.is_empty() { + return Err(CliError::Validation(format!( + "Overlay action at index {i} missing required 'target' field" + ))); + } + if action.update.is_none() && !action.remove { + return Err(CliError::Validation(format!( + "Overlay action at index {i} must have either 'update' or 'remove'" + ))); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Application +// --------------------------------------------------------------------------- + +/// Apply an overlay document to an AsyncAPI spec represented as a JSON value. +/// +/// Actions are applied sequentially; each one operates on the result of the +/// previous action. This function does **not** mutate the input — it returns a +/// new value. +pub fn apply_overlay(doc: &Value, overlay: &OverlayDocument) -> Result { + let mut output = doc.clone(); + + for (i, action) in overlay.actions.iter().enumerate() { + let path = JsonPath::parse(&action.target).map_err(|e| { + CliError::Validation(format!( + "Invalid JSONPath in overlay action {i} (target: '{}'): {e}", + action.target + )) + })?; + + if action.remove { + apply_remove(&mut output, &path); + } else if let Some(ref update) = action.update { + apply_update(&mut output, &path, update)?; + } + } + + Ok(output) +} + +/// Apply a remove action: delete all nodes matched by `path`. +fn apply_remove(doc: &mut Value, path: &JsonPath) { + let located = path.query_located(doc); + // Collect normalized paths; process in reverse so array indices stay valid + let mut paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + paths.sort_by(|a, b| b.cmp(a)); + + for segments in &paths { + remove_at_path(doc, segments); + } +} + +/// Apply an update (deep-merge) action to all nodes matched by `path`. +fn apply_update(doc: &mut Value, path: &JsonPath, update: &Value) -> Result<(), CliError> { + let located = path.query_located(doc); + let paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + + if paths.is_empty() { + return Ok(()); + } + + for segments in &paths { + if segments.is_empty() { + // Root target — merge directly into doc + if let Value::Object(_) = update { + deep_merge(doc, update); + } + } else { + merge_at_path(doc, segments, update); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Path navigation helpers +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum PathSegment { + Key(String), + Index(usize), +} + +/// Convert a `serde_json_path` `NormalizedPath` location into our own segment list. +fn normalized_path_to_segments( + location: &serde_json_path::NormalizedPath<'_>, +) -> Vec { + location + .iter() + .filter_map(|elem| { + if let Some(name) = elem.as_name() { + Some(PathSegment::Key(name.to_string())) + } else { + elem.as_index().map(PathSegment::Index) + } + }) + .collect() +} + + +/// Navigate to a path's parent and remove the target node. +fn remove_at_path(doc: &mut Value, segments: &[PathSegment]) { + if segments.is_empty() { + return; + } + + let (parent_segments, last) = segments.split_at(segments.len() - 1); + let last = &last[0]; + + let parent = navigate_to_mut(doc, parent_segments); + let Some(parent) = parent else { return }; + + match last { + PathSegment::Key(key) => { + if let Value::Object(map) = parent { + map.remove(key); + } + } + PathSegment::Index(idx) => { + if let Value::Array(arr) = parent { + if *idx < arr.len() { + arr.remove(*idx); + } + } + } + } +} + +/// Navigate to a path and deep-merge the update value. +fn merge_at_path(doc: &mut Value, segments: &[PathSegment], update: &Value) { + let target = navigate_to_mut(doc, segments); + let Some(target) = target else { return }; + + // Match Fern CLI behavior (applyOpenAPIOverlay.ts L74-77): when the target + // is an array and the update is NOT itself an array, append the value. + if let Value::Array(arr) = target { + if !update.is_array() { + arr.push(update.clone()); + return; + } + } + + deep_merge(target, update); +} + +/// Walk the JSON tree following the given segments, returning a mutable ref to +/// the target node, or `None` if the path does not exist. +fn navigate_to_mut<'a>(doc: &'a mut Value, segments: &[PathSegment]) -> Option<&'a mut Value> { + let mut current = doc; + for segment in segments { + current = match segment { + PathSegment::Key(key) => current.get_mut(key.as_str())?, + PathSegment::Index(idx) => current.get_mut(*idx)?, + }; + } + Some(current) +} + +// --------------------------------------------------------------------------- +// Deep merge +// --------------------------------------------------------------------------- + +/// Recursively merge `update` into `base`, matching lodash `merge` semantics. +/// +/// - Objects are merged key-by-key (recursive). +/// - Arrays are merged index-by-index: each element in `update` is deep-merged +/// into the corresponding index of `base`. If `update` is shorter, trailing +/// `base` elements are preserved. If `update` is longer, new elements are +/// appended. +/// - All other types are overwritten. +pub fn deep_merge(base: &mut Value, update: &Value) { + match (base, update) { + (Value::Object(base_map), Value::Object(update_map)) => { + for (key, update_val) in update_map { + let entry = base_map + .entry(key.clone()) + .or_insert(Value::Null); + deep_merge(entry, update_val); + } + } + (Value::Array(base_arr), Value::Array(update_arr)) => { + for (i, update_val) in update_arr.iter().enumerate() { + if i < base_arr.len() { + deep_merge(&mut base_arr[i], update_val); + } else { + base_arr.push(update_val.clone()); + } + } + } + (base, update) => { + *base = update.clone(); + } + } +} + +// --------------------------------------------------------------------------- +// YAML → JSON conversion +// --------------------------------------------------------------------------- + +/// Convert a `serde_yaml::Value` into a `serde_json::Value`. +fn yaml_to_json(yaml: serde_yaml::Value) -> Value { + match yaml { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s), + serde_yaml::Value::Sequence(seq) => { + Value::Array(seq.into_iter().map(yaml_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_to_json(v))) + }) + .collect(); + Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), + } +} + +/// Parse an AsyncAPI spec string (YAML or JSON) into a `serde_json::Value`, +/// apply a list of overlay strings, and return the modified JSON value +/// serialised back to a YAML string suitable for the AsyncAPI parser. +pub fn apply_overlays_to_spec( + spec_yaml: &str, + overlay_strings: &[String], +) -> Result { + if overlay_strings.is_empty() { + return Ok(spec_yaml.to_string()); + } + + // Parse spec into a generic JSON value + let yaml_value: serde_yaml::Value = serde_yaml::from_str(spec_yaml) + .map_err(|e| CliError::Discovery(format!("Failed to parse AsyncAPI spec: {e}")))?; + let mut doc = yaml_to_json(yaml_value); + + for (idx, overlay_str) in overlay_strings.iter().enumerate() { + let overlay = parse_overlay(overlay_str).map_err(|e| { + CliError::Discovery(format!("Failed to parse overlay {idx}: {e}")) + })?; + validate_overlay(&overlay).map_err(|e| { + CliError::Validation(format!("Invalid overlay {idx}: {e}")) + })?; + + tracing::debug!( + "Applying overlay \"{}\" v{}", + overlay.info.title, + overlay.info.version + ); + + doc = apply_overlay(&doc, &overlay)?; + } + + // Serialize back to YAML + serde_yaml::to_string(&doc) + .map_err(|e| CliError::Discovery(format!("Failed to serialize overlaid spec: {e}"))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // -- deep_merge -- + + #[test] + fn test_deep_merge_objects() { + let mut base = json!({"a": 1, "b": {"c": 2}}); + let update = json!({"b": {"d": 3}, "e": 4}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1, "b": {"c": 2, "d": 3}, "e": 4})); + } + + #[test] + fn test_deep_merge_overwrites_primitives() { + let mut base = json!({"a": 1}); + let update = json!({"a": 2}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 2})); + } + + #[test] + fn test_deep_merge_nested() { + let mut base = json!({"a": {"b": {"c": 1, "d": 2}}}); + let update = json!({"a": {"b": {"c": 10, "e": 3}}}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": {"b": {"c": 10, "d": 2, "e": 3}}})); + } + + // -- parse_overlay -- + + #[test] + fn test_parse_overlay_yaml() { + let yaml = r#" +overlay: "1.0.0" +info: + title: Test Overlay + version: "1.0" +actions: + - target: "$.info" + update: + description: "Updated description" +"#; + let doc = parse_overlay(yaml).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.info.title, "Test Overlay"); + assert_eq!(doc.actions.len(), 1); + } + + #[test] + fn test_parse_overlay_json() { + let json_str = r#"{ + "overlay": "1.0.0", + "info": {"title": "Test", "version": "1.0"}, + "actions": [ + {"target": "$.info", "update": {"description": "hi"}} + ] + }"#; + let doc = parse_overlay(json_str).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.actions.len(), 1); + } + + // -- validate_overlay -- + + #[test] + fn test_validate_overlay_missing_version() { + let doc = OverlayDocument { + overlay: String::new(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_no_actions() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_target() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: String::new(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_update_no_remove() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: None, + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + // -- apply_overlay: update -- + + #[test] + fn test_overlay_update_simple_path() { + let doc = json!({ + "info": {"title": "Old", "version": "1.0"}, + "paths": {} + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({"title": "New", "description": "Added"})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "1.0"); + assert_eq!(result["info"]["description"], "Added"); + } + + #[test] + fn test_overlay_update_nested_path() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_object()); + } + + // -- apply_overlay: remove -- + + #[test] + fn test_overlay_remove_property() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User.properties.email".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_null()); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: wildcard -- + + #[test] + fn test_overlay_wildcard_update() { + let doc = json!({ + "paths": { + "/users": { + "get": {"summary": "Get users"} + }, + "/posts": { + "get": {"summary": "Get posts"} + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths.*.get".into(), + description: None, + update: Some(json!({"security": [{"Bearer": []}]})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["paths"]["/users"]["get"]["security"].is_array()); + assert!(result["paths"]["/posts"]["get"]["security"].is_array()); + } + + // -- apply_overlay: zero matches -- + + #[test] + fn test_overlay_zero_match_no_error() { + let doc = json!({"info": {"title": "Test"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.nonexistent.path".into(), + description: None, + update: Some(json!({"x": 1})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result, doc); + } + + // -- apply_overlay: sequential actions -- + + #[test] + fn test_overlay_sequential_actions() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![ + OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "properties": { + "id": {"type": "string"}, + "profile": {"type": "object", "properties": {"name": {"type": "string"}}} + } + })), + remove: false, + }, + OverlayAction { + target: "$.components.schemas.User.properties.profile".into(), + description: None, + update: Some(json!({ + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"} + } + })), + remove: false, + }, + ], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["email"]["type"], + "string" + ); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: root target -- + + #[test] + fn test_overlay_root_target() { + let doc = json!({"info": {"title": "Old"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$".into(), + description: None, + update: Some(json!({"info": {"title": "New", "version": "2.0"}})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "2.0"); + } + + // -- apply_overlays_to_spec -- + + #[test] + fn test_apply_overlays_to_spec_roundtrip() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + + let result = apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + // The result should be valid YAML that can be parsed + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + let info = &parsed["info"]; + assert_eq!(info["description"], serde_yaml::Value::String("A plant management API".into())); + // Original fields preserved + assert_eq!(info["title"], serde_yaml::Value::String("Test API".into())); + } + + #[test] + fn test_apply_overlays_to_spec_no_overlays() { + let spec = "openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\n"; + let result = apply_overlays_to_spec(spec, &[]).unwrap(); + assert_eq!(result, spec); + } + + // -- array removal -- + + #[test] + fn test_overlay_remove_array_element() { + let doc = json!({ + "paths": { + "/plants": { + "get": { + "parameters": [ + {"name": "id", "in": "query"}, + {"name": "limit", "in": "query"}, + {"name": "offset", "in": "query"} + ] + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths['/plants'].get.parameters[1]".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params.len(), 2); + assert_eq!(params[0]["name"], "id"); + assert_eq!(params[1]["name"], "offset"); + } + + // -- multiple overlays -- + + #[test] + fn test_apply_multiple_overlays() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: {} +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "First overlay" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store Support" +"#; + let result = apply_overlays_to_spec(spec, &[overlay1.to_string(), overlay2.to_string()]).unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!( + parsed["info"]["description"], + serde_yaml::Value::String("First overlay".into()) + ); + assert_eq!( + parsed["info"]["contact"]["name"], + serde_yaml::Value::String("Plant Store Support".into()) + ); + } + + // -- deep merge preserves existing keys -- + + #[test] + fn test_deep_merge_preserves_existing() { + let doc = json!({ + "components": { + "schemas": { + "Plant": { + "type": "object", + "properties": { + "species": {"type": "string"}, + "height": {"type": "number"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.Plant.properties".into(), + description: None, + update: Some(json!({ + "species": {"type": "string", "description": "The plant species"}, + "color": {"type": "string"} + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["height"]["type"], "number"); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["species"]["description"], + "The plant species" + ); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["color"]["type"], "string"); + } + + // ----------------------------------------------------------------------- + // Tests ported from Fern CLI TypeScript (applyOpenAPIOverlay.test.ts) + // These ensure behavioral parity with the Fern CLI overlay implementation. + // ----------------------------------------------------------------------- + + fn make_overlay(actions: Vec) -> OverlayDocument { + OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "Test".into(), version: "1.0".into() }, + extends: None, + actions, + } + } + + fn update_action(target: &str, update: Value) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: Some(update), + remove: false, + } + } + + fn remove_action(target: &str) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: None, + remove: true, + } + } + + /// Port of TS: "should merge updates into a schema at a JSONPath target" + #[test] + fn test_fern_merge_updates_into_schema() { + let doc = json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.UserUpdate", + json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }) + ); + } + + /// Port of TS: "should merge arrays of objects in OpenAPI paths" + /// Uses filter expression to target a specific array element. + #[test] + fn test_fern_merge_array_element_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.name=='id')]", + json!({ "name": "id", "in": "query", "required": true, "description": "Plant ID" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true, "description": "Plant ID" }, + { "name": "limit", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should replace arrays of primitives" + /// When both target and update are arrays, lodash-style index-by-index merge. + #[test] + fn test_fern_replace_primitive_arrays() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": ["annual", "perennial"] + }} + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.tags.enum", + json!(["tropical", "succulent"]), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["tags"]["enum"], + json!(["tropical", "succulent"]) + ); + } + + /// Port of TS: "should ignore updates if remove is true" + #[test] + fn test_fern_remove_ignores_update() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" }, + "toxicity": { "type": "string" } + } + }}} + }); + let overlay = make_overlay(vec![OverlayAction { + target: "$.components.schemas.Plant.properties.toxicity".into(), + description: None, + update: Some(json!({ "type": "string", "format": "enum" })), + remove: true, + }]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" } + } + }}} + }) + ); + } + + /// Port of TS: "should handle multiple consecutive array removals" + #[test] + fn test_fern_multiple_consecutive_array_removals() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![ + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'limit')]"), + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'offset')]"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true }, + { "name": "sort", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should handle merges to multiple items in an array" + #[test] + fn test_fern_merge_multiple_array_items_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "authorization", "in": "header", "required": true }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.in == 'query')]", + json!({ "description": "Query parameter" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params[0]["description"], "Query parameter"); + assert_eq!(params[1]["description"], "Query parameter"); + assert!(params[2].get("description").is_none()); // header param untouched + assert_eq!(params[3]["description"], "Query parameter"); + assert_eq!(params[4]["description"], "Query parameter"); + } + + /// Port of TS: "should handle multiple overlay actions" + #[test] + fn test_fern_multiple_overlay_actions() { + let doc = json!({ + "components": { "schemas": { + "PlantUpdate": { + "type": "object", + "properties": { "species": { "type": "string" } } + }, + "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.PlantUpdate", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["PlantUpdate"]["properties"]["color"].is_object()); + assert!(result["components"]["schemas"]["Plant"]["properties"]["species"].is_object()); + } + + /// Port of TS: "should handle actions on items inserted by earlier actions" + #[test] + fn test_fern_actions_on_items_from_earlier_actions() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + }}} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "habitat": { + "type": "object", + "properties": { "climate": { "type": "string" } } + } + } + }), + ), + update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { "type": "string" }, + "soil": { "type": "string", "format": "enum" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + assert!(habitat["climate"].is_object()); + assert_eq!(habitat["soil"]["format"], "enum"); + } + + /// Port of TS: "should handle wildcard path matching across multiple paths" + #[test] + fn test_fern_wildcard_across_multiple_paths() { + let doc = json!({ + "paths": { + "/plants": { + "get": { "summary": "Get plants", "operationId": "getPlants" }, + "post": { "summary": "Create plant", "operationId": "createPlant" } + }, + "/gardens": { + "get": { "summary": "Get gardens", "operationId": "getGardens" } + }, + "/nurseries": { + "get": { "summary": "Get nurseries", "operationId": "getNurseries" }, + "delete": { "summary": "Delete nursery", "operationId": "deleteNursery" } + } + } + }); + let overlay = make_overlay(vec![update_action( + "$.paths.*.get", + json!({ "security": [{ "Bearer": [] }] }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // All GET operations should have security + assert!(result["paths"]["/plants"]["get"]["security"].is_array()); + assert!(result["paths"]["/gardens"]["get"]["security"].is_array()); + assert!(result["paths"]["/nurseries"]["get"]["security"].is_array()); + // Non-GET operations should not + assert!(result["paths"]["/plants"]["post"].get("security").is_none()); + assert!(result["paths"]["/nurseries"]["delete"].get("security").is_none()); + } + + /// Port of TS: "should handle zero-match JSONPath expressions" + #[test] + fn test_fern_zero_match_continues_processing() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }}}, + "paths": { "/plants": { "get": { "summary": "Get plants" } } } + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.NonExistentSchema", + json!({ "type": "object" }), + ), + update_action( + "$.paths['/nonexistent'].post", + json!({ "summary": "Non-existent" }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" }, + "color": { "type": "string", "format": "hex" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Only the last valid action should have taken effect + assert!(result["components"]["schemas"]["Plant"]["properties"]["color"].is_object()); + // Original data untouched where no match + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle deep merge behavior" + #[test] + fn test_fern_deep_merge_preserves_nested_structure() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "habitat": { + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "humidity": { "type": "integer" } + } + }, + "soil": { + "type": "object", + "properties": { "ph": { "type": "string" } } + } + } + }, + "care": { + "type": "object", + "properties": { + "watering": { "type": "string", "default": "weekly" } + } + } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "rainfall": { "type": "string" } + } + }, + "soil": { + "type": "object", + "properties": { + "ph": { "type": "string" }, + "drainage": { "type": "string", "format": "enum" } + } + }, + "sunlight": { + "type": "object", + "properties": { + "hours": { "type": "integer", "default": 6 } + } + } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + // Existing humidity preserved + assert_eq!(habitat["climate"]["properties"]["humidity"]["type"], "integer"); + // New rainfall added + assert_eq!(habitat["soil"]["properties"]["drainage"]["format"], "enum"); + // New sunlight section added + assert_eq!(habitat["sunlight"]["properties"]["hours"]["default"], 6); + // care section untouched + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["care"]["properties"]["watering"]["default"], + "weekly" + ); + } + + /// Port of TS: "should handle root-level targeting" + #[test] + fn test_fern_root_level_targeting() { + let doc = json!({ + "openapi": "3.0.0", + "info": { "title": "Plant API", "version": "1.0.0" }, + "paths": { "/plants": { "get": { "summary": "Get plants" } } }, + "tags": [{ "name": "legacy", "description": "Legacy endpoints" }], + "components": { "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$", + json!({ + "openapi": "3.0.0", + "info": { + "title": "Plant API", + "version": "1.0.0", + "description": "API for managing plants and gardens", + "contact": { "name": "Garden Team", "email": "garden@example.com" } + }, + "servers": [ + { "url": "https://api.example.com/v1", "description": "Production" }, + { "url": "https://staging.example.com/v1", "description": "Staging" } + ], + "externalDocs": { + "description": "Plant care guide", + "url": "https://docs.example.com" + } + }), + ), + remove_action("$.tags"), + remove_action("$.components"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Added fields + assert_eq!(result["info"]["description"], "API for managing plants and gardens"); + assert!(result["servers"].is_array()); + assert_eq!(result["servers"].as_array().unwrap().len(), 2); + assert!(result["externalDocs"].is_object()); + // Removed fields + assert!(result.get("tags").is_none()); + assert!(result.get("components").is_none()); + // Preserved fields + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle array edge cases including empty arrays and + /// replacing complete arrays" + #[test] + fn test_fern_array_edge_cases_append_and_replace() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": [] + }, + "zones": { + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5"] + }, + "companions": { + "type": "array", + "items": { "type": "object" }, + "enum": [] + } + } + }}} + }); + let overlay = make_overlay(vec![ + // Replace whole tags object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.tags", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["tropical", "succulent"] + }), + ), + // Replace whole zones object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.zones", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5", "zone6", "zone7"] + }), + ), + // Append object to empty companions array + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "basil", "benefit": "pest control" }), + ), + // Append another object + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "marigold", "benefit": "pollination" }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let props = &result["components"]["schemas"]["Plant"]["properties"]; + assert_eq!(props["tags"]["enum"], json!(["tropical", "succulent"])); + assert_eq!(props["zones"]["enum"], json!(["zone5", "zone6", "zone7"])); + let companions = props["companions"]["enum"].as_array().unwrap(); + assert_eq!(companions.len(), 2); + assert_eq!(companions[0]["name"], "basil"); + assert_eq!(companions[1]["name"], "marigold"); + } + + /// Port of TS: "should not mutate the input data object" + #[test] + fn test_fern_does_not_mutate_input() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "species": { "type": "string" } } + }}} + }); + let original = doc.clone(); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + )]); + let _result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(doc, original); + } + + /// Port of TS: "should handle complex JSONPath expressions including + /// recursive descent and filters" — array index targeting + #[test] + fn test_fern_array_index_targeting() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer" } }, + { "name": "offset", "in": "query", "schema": { "type": "integer" } } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[0]", + json!({ + "name": "limit", "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 100 }, + "description": "Maximum number of items to return" + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = &result["paths"]["/plants"]["get"]["parameters"]; + assert_eq!(params[0]["description"], "Maximum number of items to return"); + assert_eq!(params[0]["schema"]["minimum"], 1); + // Second param untouched + assert!(params[1].get("description").is_none()); + } + + // -- Additional deep_merge tests for lodash parity -- + + /// Verify lodash-style index-by-index array merge + #[test] + fn test_deep_merge_arrays_index_by_index() { + let mut base = json!([1, 2, 3]); + let update = json!([10, 20]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 3])); + } + + /// Verify array merge with objects inside arrays + #[test] + fn test_deep_merge_arrays_of_objects() { + let mut base = json!([ + { "name": "a", "value": 1 }, + { "name": "b", "value": 2 } + ]); + let update = json!([ + { "name": "a", "value": 10, "extra": true } + ]); + deep_merge(&mut base, &update); + assert_eq!(base[0]["value"], 10); + assert_eq!(base[0]["extra"], true); + assert_eq!(base[1]["value"], 2); // second element preserved + } + + /// Verify array append appends objects to array target + #[test] + fn test_merge_at_path_array_append() { + let mut doc = json!({ "items": [] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({ "id": 1 })); + merge_at_path(&mut doc, &segments, &json!({ "id": 2 })); + assert_eq!(doc["items"], json!([{ "id": 1 }, { "id": 2 }])); + } + + /// Verify that update with longer array extends the base + #[test] + fn test_deep_merge_update_extends_shorter_array() { + let mut base = json!([1]); + let update = json!([10, 20, 30]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 1 verification: array append scope — widened guard pushes any + // non-array value (objects, strings, numbers, booleans, null) matching + // the Fern CLI TS behavior. + // ----------------------------------------------------------------------- + + #[test] + fn test_array_append_object() { + let mut doc = json!({ "items": [{"id": 1}] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({"id": 2})); + assert_eq!(doc["items"], json!([{"id": 1}, {"id": 2}])); + } + + #[test] + fn test_array_append_string() { + let mut doc = json!({ "tags": ["a", "b"] }); + let segments = vec![PathSegment::Key("tags".into())]; + merge_at_path(&mut doc, &segments, &json!("c")); + assert_eq!(doc["tags"], json!(["a", "b", "c"])); + } + + #[test] + fn test_array_append_number() { + let mut doc = json!({ "nums": [1, 2] }); + let segments = vec![PathSegment::Key("nums".into())]; + merge_at_path(&mut doc, &segments, &json!(3)); + assert_eq!(doc["nums"], json!([1, 2, 3])); + } + + #[test] + fn test_array_append_boolean() { + let mut doc = json!({ "flags": [true] }); + let segments = vec![PathSegment::Key("flags".into())]; + merge_at_path(&mut doc, &segments, &json!(false)); + assert_eq!(doc["flags"], json!([true, false])); + } + + #[test] + fn test_array_append_null() { + let mut doc = json!({ "items": [1] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &Value::Null); + assert_eq!(doc["items"], json!([1, null])); + } + + #[test] + fn test_array_replace_with_array() { + let mut doc = json!({ "items": [1, 2] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!([10, 20, 30])); + // Arrays merge index-by-index via deep_merge + assert_eq!(doc["items"], json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 2 verification: lodash merge vs deep_merge edge cases + // ----------------------------------------------------------------------- + + #[test] + fn test_deep_merge_arrays_of_arrays() { + let mut base = json!([[1, 2], [3, 4]]); + let update = json!([[10], [30, 40, 50]]); + deep_merge(&mut base, &update); + // Index-by-index: base[0] merges with [10], base[1] with [30,40,50] + assert_eq!(base, json!([[10, 2], [30, 40, 50]])); + } + + #[test] + fn test_deep_merge_mixed_type_arrays() { + let mut base = json!([1, "hello", {"a": 1}, [1, 2]]); + let update = json!([99, "world", {"b": 2}, [3]]); + deep_merge(&mut base, &update); + // Primitives replaced, objects merged, arrays merged index-by-index + assert_eq!(base, json!([99, "world", {"a": 1, "b": 2}, [3, 2]])); + } + + #[test] + fn test_deep_merge_sparse_like_arrays() { + // lodash.merge with sparse arrays fills gaps — our impl uses + // index-by-index so shorter base just gets extended + let mut base = json!([1]); + let update = json!([null, null, 3]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([null, null, 3])); + } + + #[test] + fn test_deep_merge_empty_arrays() { + let mut base = json!([1, 2, 3]); + let update = json!([]); + deep_merge(&mut base, &update); + // Empty update leaves base unchanged + assert_eq!(base, json!([1, 2, 3])); + } + + #[test] + fn test_deep_merge_nested_objects_in_arrays() { + let mut base = json!([{"a": {"x": 1}}, {"b": 2}]); + let update = json!([{"a": {"y": 2}}, {"c": 3}]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([{"a": {"x": 1, "y": 2}}, {"b": 2, "c": 3}])); + } + + #[test] + fn test_deep_merge_array_type_mismatch_replaces() { + // When base is object and update is array (or vice versa), replace + let mut base = json!({"a": 1}); + let update = json!([1, 2]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([1, 2])); + + let mut base = json!([1, 2]); + let update = json!({"a": 1}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1})); + } + + // ----------------------------------------------------------------------- + // Item 3 verification: YAML ↔ JSON roundtrip fidelity + // ----------------------------------------------------------------------- + + #[test] + fn test_yaml_roundtrip_strips_comments() { + let yaml_with_comments = r#" +openapi: "3.0.0" +info: + title: Test # inline comment + version: "1.0" +# full line comment +paths: {} +"#; + // Need a no-op overlay to trigger the YAML->JSON->YAML roundtrip + // (empty overlay list short-circuits and returns original string) + let noop_overlay = r#" +overlay: "1.0.0" +info: + title: noop + version: "1.0.0" +actions: + - target: "$.__nonexistent__" + update: + x: 1 +"#; + let result = apply_overlays_to_spec( + yaml_with_comments, + &[noop_overlay.to_string()], + ) + .unwrap(); + // Comments are stripped after roundtrip + assert!(!result.contains("# inline comment"), "inline comment should be stripped: {result}"); + assert!(!result.contains("# full line comment"), "line comment should be stripped: {result}"); + assert!(result.contains("title: Test")); + } + + #[test] + fn test_yaml_roundtrip_resolves_anchors() { + // serde_yaml resolves anchors/aliases during deserialization. + // Use a simple alias (not merge key) to verify resolution. + let yaml_with_anchors = r#" +base_url: &url "https://api.example.com" +servers: + - url: *url + description: production +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_anchors).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Alias is resolved to the concrete value + assert_eq!( + json_val["servers"][0]["url"], + "https://api.example.com" + ); + assert_eq!( + json_val["servers"][0]["description"], + "production" + ); + // The anchor definition is also present as a regular key + assert_eq!( + json_val["base_url"], + "https://api.example.com" + ); + } + + #[test] + fn test_yaml_roundtrip_strips_custom_tags() { + let yaml_with_tag = r#" +value: !custom_tag + inner: data +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_tag).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Custom tags are stripped, value preserved + assert_eq!(json_val["value"]["inner"], "data"); + } + + #[test] + fn test_yaml_roundtrip_with_overlay_preserves_structure() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API # comment will be stripped + version: "1.0" +paths: + /users: + get: + summary: List users +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: add-description + version: "1.0.0" +actions: + - target: "$.info" + update: + description: "Added by overlay" +"#; + let result = + apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + assert!(result.contains("description: Added by overlay")); + assert!(result.contains("title: Test API")); + assert!(!result.contains('#')); + } + + // ----------------------------------------------------------------------- + // Item 4 verification: special characters in JSON keys via overlay paths + // ----------------------------------------------------------------------- + + #[test] + fn test_overlay_key_with_special_chars() { + let doc = json!({ + "x-extension": {"value": 1}, + "paths": { + "/users/{id}": { + "get": {"summary": "get user"} + } + } + }); + let overlay = make_overlay(vec![ + update_action( + "$.paths['/users/{id}'].get", + json!({"description": "Get a user by ID"}), + ), + update_action( + "$['x-extension']", + json!({"extra": true}), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/users/{id}"]["get"]["description"], + "Get a user by ID" + ); + assert_eq!(result["x-extension"]["extra"], true); + assert_eq!(result["x-extension"]["value"], 1); + } + + #[test] + fn test_normalized_path_to_segments_direct() { + // Verify the iterator-based approach works for keys with special chars + let doc = json!({ + "it's": {"nested": true}, + "key[0]": "bracket-key" + }); + let path = serde_json_path::JsonPath::parse("$[\"it's\"]").unwrap(); + let located = path.query_located(&doc); + for node in located.iter() { + let segments = normalized_path_to_segments(node.location()); + assert_eq!(segments, vec![PathSegment::Key("it's".into())]); + } + } + + // ----------------------------------------------------------------------- + // AsyncAPI-specific overlay tests — exercise update / remove / zero-match + // against a parsed AsyncAPI document. Mirrors the structure of the + // openapi fixture tests but stays inside the AsyncAPI path. + // ----------------------------------------------------------------------- + + const SAMPLE_ASYNCAPI_YAML: &str = r##" +asyncapi: "2.6.0" +info: + title: Sample + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + AgentMessages: + description: Original description + subscribe: + summary: incoming + message: + $ref: "#/components/messages/UserAudio" +components: + messages: + UserAudio: + payload: {} +"##; + + fn parse_asyncapi_yaml(yaml: &str) -> serde_json::Value { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(yaml) + .expect("YAML must parse for overlay tests"); + yaml_to_json(yaml_value) + } + + #[test] + fn overlay_update_action_deep_merges_into_parsed_asyncapi() { + let doc = parse_asyncapi_yaml(SAMPLE_ASYNCAPI_YAML); + let overlay = make_overlay(vec![update_action( + "$.channels.AgentMessages", + json!({ "description": "Updated description" }), + )]); + + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["channels"]["AgentMessages"]["description"], + "Updated description" + ); + // Pre-existing children preserved (subscribe block intact) + assert!(result["channels"]["AgentMessages"]["subscribe"].is_object()); + } + + #[test] + fn overlay_remove_action_removes_node_from_parsed_asyncapi() { + let doc = parse_asyncapi_yaml(SAMPLE_ASYNCAPI_YAML); + let overlay = make_overlay(vec![remove_action( + "$.channels.AgentMessages.subscribe", + )]); + + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!( + result["channels"]["AgentMessages"] + .get("subscribe") + .is_none(), + "subscribe should be removed: {:?}", + result["channels"]["AgentMessages"] + ); + // Sibling description survives the removal. + assert_eq!( + result["channels"]["AgentMessages"]["description"], + "Original description" + ); + } + + #[test] + fn overlay_jsonpath_filter_edge_case_no_matches() { + let doc = parse_asyncapi_yaml(SAMPLE_ASYNCAPI_YAML); + let overlay = make_overlay(vec![update_action( + "$.channels.AgentMessages.subscribe.message.oneOf[?(@.name == 'DoesNotExist')]", + json!({ "description": "should not be applied" }), + )]); + + let result = apply_overlay(&doc, &overlay).expect( + "zero-match overlay must succeed without error", + ); + // Document is unchanged. + assert_eq!(result, doc); + } +} diff --git a/src/asyncapi/parser.rs b/src/asyncapi/parser.rs new file mode 100644 index 0000000..e793950 --- /dev/null +++ b/src/asyncapi/parser.rs @@ -0,0 +1,686 @@ +//! AsyncAPI 2.6 parser. +//! +//! Accepts a YAML or JSON AsyncAPI document and produces an +//! [`AsyncApiDescription`]. Rejects unsupported AsyncAPI versions and any +//! non-WebSocket server protocol with [`CliError::Validation`]. +//! +//! This module is intentionally self-contained — it must not import from +//! `crate::openapi` or `crate::graphql`. See `AGENTS.md` ("Architecture: +//! Code Generation Model"). + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::error::CliError; + +use super::discovery::{ + AsyncApiDescription, Channel, ChannelParameter, Info, Message, Operation, Server, +}; + +/// AsyncAPI specification version this parser supports (any `2.6.x`). +const SUPPORTED_VERSION_PREFIX: &str = "2.6."; + +/// Server protocols accepted by this parser — WebSocket only. +const SUPPORTED_PROTOCOLS: &[&str] = &["ws", "wss"]; + +/// Parse an AsyncAPI 2.6 document from YAML or JSON. +/// +/// # Errors +/// +/// Returns [`CliError::Validation`] when: +/// - the top-level `asyncapi` version field is missing or not `2.6.x`, +/// - any server declares a protocol other than `ws` / `wss`. +/// +/// Returns [`CliError::Discovery`] when YAML/JSON deserialization fails. +pub fn parse(input: &str) -> Result { + // Parse into a generic JSON value first. Try JSON, then YAML — mirrors + // the openapi overlay loader strategy. + let doc: Value = serde_json::from_str::(input).or_else(|_| { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(input).map_err(|e| { + CliError::Discovery(format!("Failed to parse AsyncAPI document: {e}")) + })?; + Ok::(yaml_to_json(yaml_value)) + })?; + + let obj = doc.as_object().ok_or_else(|| { + CliError::Validation( + "AsyncAPI document must be a mapping at the top level".to_string(), + ) + })?; + + // ---- Version guard -------------------------------------------------- + let version = obj.get("asyncapi").and_then(Value::as_str).ok_or_else(|| { + CliError::Validation( + "AsyncAPI document is missing required `asyncapi` version field; \ + only AsyncAPI 2.6.x is supported" + .to_string(), + ) + })?; + if !version.starts_with(SUPPORTED_VERSION_PREFIX) { + return Err(CliError::Validation(format!( + "Unsupported AsyncAPI version `{version}`; only AsyncAPI 2.6.x is supported" + ))); + } + + // ---- Protocol guard ------------------------------------------------- + if let Some(servers) = obj.get("servers").and_then(Value::as_object) { + for (name, server) in servers { + let protocol = server + .get("protocol") + .and_then(Value::as_str) + .unwrap_or_default(); + if !SUPPORTED_PROTOCOLS.contains(&protocol) { + return Err(CliError::Validation(format!( + "Server `{name}` declares unsupported protocol `{protocol}`; \ + only WebSocket (ws, wss) is supported" + ))); + } + } + } + + // ---- Build the description ------------------------------------------ + let info = obj + .get("info") + .cloned() + .map(|v| serde_json::from_value::(v).unwrap_or_default()) + .unwrap_or_default(); + + let servers = obj + .get("servers") + .and_then(Value::as_object) + .map(parse_servers) + .unwrap_or_default(); + + let channels = obj + .get("channels") + .and_then(Value::as_object) + .map(parse_channels) + .unwrap_or_default(); + + let (messages, schemas) = obj + .get("components") + .and_then(Value::as_object) + .map(parse_components) + .unwrap_or_default(); + + Ok(AsyncApiDescription { + asyncapi: version.to_string(), + info, + servers, + channels, + messages, + schemas, + }) +} + +// --------------------------------------------------------------------------- +// Component parsing helpers +// --------------------------------------------------------------------------- + +fn parse_servers(servers: &Map) -> HashMap { + servers + .iter() + .map(|(name, value)| { + let server = serde_json::from_value::(value.clone()).unwrap_or_default(); + (name.clone(), server) + }) + .collect() +} + +fn parse_channels(channels: &Map) -> HashMap { + channels + .iter() + .map(|(name, value)| (name.clone(), parse_channel(value))) + .collect() +} + +fn parse_channel(value: &Value) -> Channel { + let map = match value.as_object() { + Some(map) => map, + None => return Channel::default(), + }; + + let description = map + .get("description") + .and_then(Value::as_str) + .map(str::to_string); + let publish = map.get("publish").map(parse_operation); + let subscribe = map.get("subscribe").map(parse_operation); + let parameters = map + .get("parameters") + .and_then(Value::as_object) + .map(parse_parameters) + .unwrap_or_default(); + + // `x-fern-sdk-group-name` is an array of strings (nested group path); + // `x-fern-sdk-method-name` is a single string (leaf command name). + // Mirrors the OpenAPI extension shape — same semantics, different host. + let sdk_group_name = map + .get("x-fern-sdk-group-name") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(|entry| entry.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let sdk_method_name = map + .get("x-fern-sdk-method-name") + .and_then(Value::as_str) + .map(str::to_string); + + // `x-fern-init-payload` — opaque JSON value that the executor will + // send as the first WS frame after connect. Preserved verbatim + // (`Value::clone`) so nested objects survive round-trips. Channels + // without the extension yield `None`. + let x_fern_init_payload = map.get("x-fern-init-payload").cloned(); + + Channel { + description, + publish, + subscribe, + parameters, + sdk_group_name, + sdk_method_name, + x_fern_init_payload, + } +} + +fn parse_operation(value: &Value) -> Operation { + let map = match value.as_object() { + Some(map) => map, + None => return Operation::default(), + }; + + Operation { + operation_id: map + .get("operationId") + .and_then(Value::as_str) + .map(str::to_string), + summary: map + .get("summary") + .and_then(Value::as_str) + .map(str::to_string), + description: map + .get("description") + .and_then(Value::as_str) + .map(str::to_string), + message_refs: extract_message_refs(map.get("message")), + } +} + +/// Walk an operation's `message` node and return the bare component message +/// names referenced. Supports both a single `$ref` and `oneOf: [...]`. +fn extract_message_refs(message: Option<&Value>) -> Vec { + let Some(message) = message else { + return Vec::new(); + }; + + // Single `$ref` + if let Some(name) = message + .get("$ref") + .and_then(Value::as_str) + .and_then(strip_message_ref) + { + return vec![name.to_string()]; + } + + // `oneOf: [{$ref}, ...]` + if let Some(one_of) = message.get("oneOf").and_then(Value::as_array) { + return one_of + .iter() + .filter_map(|entry| { + entry + .get("$ref") + .and_then(Value::as_str) + .and_then(strip_message_ref) + .map(str::to_string) + }) + .collect(); + } + + Vec::new() +} + +/// Strip the `#/components/messages/` prefix from a `$ref` and return the +/// bare component name, or `None` if the ref points elsewhere. +fn strip_message_ref(reference: &str) -> Option<&str> { + reference.strip_prefix("#/components/messages/") +} + +fn parse_parameters(params: &Map) -> HashMap { + params + .iter() + .map(|(name, value)| { + let map = value.as_object(); + let description = map + .and_then(|m| m.get("description")) + .and_then(Value::as_str) + .map(str::to_string); + let schema = map + .and_then(|m| m.get("schema")) + .cloned() + .unwrap_or(Value::Null); + ( + name.clone(), + ChannelParameter { + description, + schema, + }, + ) + }) + .collect() +} + +fn parse_components( + components: &Map, +) -> (HashMap, HashMap) { + let messages = components + .get("messages") + .and_then(Value::as_object) + .map(parse_messages) + .unwrap_or_default(); + + let schemas = components + .get("schemas") + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + + (messages, schemas) +} + +fn parse_messages(messages: &Map) -> HashMap { + messages + .iter() + .map(|(name, value)| { + let map = value.as_object(); + let payload = map + .and_then(|m| m.get("payload")) + .cloned() + .unwrap_or(Value::Null); + let message = Message { + name: map + .and_then(|m| m.get("name")) + .and_then(Value::as_str) + .map(str::to_string), + title: map + .and_then(|m| m.get("title")) + .and_then(Value::as_str) + .map(str::to_string), + description: map + .and_then(|m| m.get("description")) + .and_then(Value::as_str) + .map(str::to_string), + payload, + }; + (name.clone(), message) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// YAML → JSON conversion +// --------------------------------------------------------------------------- + +/// Convert a `serde_yaml::Value` into a `serde_json::Value`. +fn yaml_to_json(yaml: serde_yaml::Value) -> Value { + match yaml { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s), + serde_yaml::Value::Sequence(seq) => { + Value::Array(seq.into_iter().map(yaml_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_to_json(v))) + }) + .collect(); + Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_validation_error(err: CliError, fragment: &str) -> String { + match err { + CliError::Validation(msg) => { + assert!( + msg.contains(fragment), + "expected error to contain `{fragment}`, got: {msg}" + ); + msg + } + other => panic!("expected Validation error, got: {other:?}"), + } + } + + // -- Happy path: the real ElevenLabs spec --------------------------------- + + #[test] + fn parse_real_elevenlabs_spec_returns_ok_with_agentmessages_and_31_messages() { + let spec = include_str!("agent.asyncapi.yaml"); + let api = parse(spec).expect("real spec must parse"); + + assert!( + api.channels.contains_key("AgentMessages"), + "AgentMessages channel must be present" + ); + assert_eq!( + api.messages.len(), + 31, + "expected 31 component messages, got {}", + api.messages.len() + ); + assert_eq!(api.asyncapi, "2.6.0"); + } + + // -- Version guard -------------------------------------------------------- + + fn minimal_spec_with_version(version: &str) -> String { + format!( + r#"asyncapi: "{version}" +info: + title: Test + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + Main: + description: trivial +"#, + ) + } + + #[test] + fn parse_rejects_asyncapi_3_0_0() { + let err = parse(&minimal_spec_with_version("3.0.0")) + .expect_err("3.0.0 must be rejected"); + assert_validation_error(err, "3.0.0"); + } + + #[test] + fn parse_rejects_asyncapi_2_5_0() { + let err = parse(&minimal_spec_with_version("2.5.0")) + .expect_err("2.5.0 must be rejected"); + assert_validation_error(err, "2.5.0"); + } + + #[test] + fn parse_rejects_asyncapi_1_2_0() { + let err = parse(&minimal_spec_with_version("1.2.0")) + .expect_err("1.2.0 must be rejected"); + assert_validation_error(err, "1.2.0"); + } + + #[test] + fn parse_rejects_missing_asyncapi_field() { + let spec = r#" +info: + title: Test + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + Main: {} +"#; + let err = parse(spec).expect_err("missing version must be rejected"); + let msg = assert_validation_error(err, "asyncapi"); + assert!( + msg.contains("missing") || msg.contains("Missing"), + "expected `missing` mention, got: {msg}" + ); + } + + // -- Protocol guard ------------------------------------------------------- + + fn minimal_spec_with_protocol(protocol: &str) -> String { + format!( + r#"asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: example.com + protocol: {protocol} +channels: + Main: {{}} +"#, + ) + } + + #[test] + fn parse_rejects_protocol_mqtt() { + let err = parse(&minimal_spec_with_protocol("mqtt")) + .expect_err("mqtt must be rejected"); + let msg = assert_validation_error(err, "mqtt"); + assert!(msg.contains("prod"), "expected server name, got: {msg}"); + } + + #[test] + fn parse_rejects_protocol_kafka() { + let err = parse(&minimal_spec_with_protocol("kafka")) + .expect_err("kafka must be rejected"); + assert_validation_error(err, "kafka"); + } + + #[test] + fn parse_rejects_protocol_amqp() { + let err = parse(&minimal_spec_with_protocol("amqp")) + .expect_err("amqp must be rejected"); + assert_validation_error(err, "amqp"); + } + + #[test] + fn parse_rejects_protocol_sse() { + let err = parse(&minimal_spec_with_protocol("sse")) + .expect_err("sse must be rejected"); + assert_validation_error(err, "sse"); + } + + #[test] + fn parse_accepts_ws_and_wss_protocols() { + let spec = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + insecure: + url: ws://localhost:8080 + protocol: ws + secure: + url: wss://example.com + protocol: wss +channels: + Main: {} +"#; + let api = parse(spec).expect("ws + wss must be accepted"); + assert_eq!(api.servers.len(), 2); + } + + // -- Message-ref extraction ---------------------------------------------- + + #[test] + fn parse_extracts_message_refs_from_oneof() { + let spec = r##" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + Main: + publish: + message: + oneOf: + - $ref: "#/components/messages/Foo" + - $ref: "#/components/messages/Bar" +components: + messages: + Foo: + payload: {} + Bar: + payload: {} +"##; + let api = parse(spec).expect("must parse"); + let channel = api.channels.get("Main").expect("Main channel present"); + let publish = channel.publish.as_ref().expect("publish op present"); + assert_eq!(publish.message_refs, vec!["Foo".to_string(), "Bar".to_string()]); + } + + #[test] + fn parse_extracts_message_ref_from_single_ref() { + let spec = r##" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + Main: + subscribe: + message: + $ref: "#/components/messages/Solo" +components: + messages: + Solo: + payload: {} +"##; + let api = parse(spec).expect("must parse"); + let channel = api.channels.get("Main").expect("Main channel present"); + let subscribe = channel.subscribe.as_ref().expect("subscribe op present"); + assert_eq!(subscribe.message_refs, vec!["Solo".to_string()]); + } + + // -- x-fern-init-payload -------------------------------------------------- + + #[test] + fn parse_populates_x_fern_init_payload_when_present() { + let spec = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + AgentMessages: + x-fern-init-payload: + type: conversation_initiation_client_data + conversation_config_override: + agent: + language: en +"#; + let api = parse(spec).expect("must parse"); + let channel = api.channels.get("AgentMessages").expect("channel present"); + let payload = channel + .x_fern_init_payload + .as_ref() + .expect("init payload should be Some"); + assert_eq!(payload["type"], "conversation_initiation_client_data"); + assert_eq!( + payload["conversation_config_override"]["agent"]["language"], + "en", + ); + } + + #[test] + fn parse_yields_none_when_x_fern_init_payload_absent() { + let spec = r#" +asyncapi: "2.6.0" +info: + title: Test + version: "1.0" +servers: + prod: + url: wss://example.com + protocol: ws +channels: + AgentMessages: + description: no init payload here +"#; + let api = parse(spec).expect("must parse"); + let channel = api.channels.get("AgentMessages").expect("channel present"); + assert!(channel.x_fern_init_payload.is_none()); + } + + #[test] + fn parse_extracts_real_spec_message_refs_for_agentmessages() { + let spec = include_str!("agent.asyncapi.yaml"); + let api = parse(spec).expect("real spec must parse"); + let channel = api + .channels + .get("AgentMessages") + .expect("AgentMessages present"); + let publish_refs = &channel + .publish + .as_ref() + .expect("publish op present") + .message_refs; + let subscribe_refs = &channel + .subscribe + .as_ref() + .expect("subscribe op present") + .message_refs; + assert!( + publish_refs.contains(&"Audio".to_string()), + "publish should include Audio, got: {publish_refs:?}" + ); + assert!( + subscribe_refs.contains(&"UserAudio".to_string()), + "subscribe should include UserAudio, got: {subscribe_refs:?}" + ); + // Sanity: combined refs should sum to the 31 declared messages. + let total = publish_refs.len() + subscribe_refs.len(); + assert_eq!(total, 31, "expected 31 refs total, got {total}"); + } +} diff --git a/src/auth/builder.rs b/src/auth/builder.rs new file mode 100644 index 0000000..3f64b7a --- /dev/null +++ b/src/auth/builder.rs @@ -0,0 +1,948 @@ +//! Builder bindings: how the `CliApp` builder records "bind credential X to +//! scheme Y" before the doc is parsed, and how those bindings are lowered +//! into a concrete [`DynAuthProvider`] once the doc is available. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::compose::{AllAuthProvider, AnyAuthProvider, RoutingAuthProvider}; +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::{DynAuthProvider, NoAuthProvider}; +use crate::auth::schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; + +/// How the bound auth schemes should compose into a single +/// [`DynAuthProvider`]. Generators that already know their API's auth +/// model can pick the right strategy explicitly; hand-written CLIs can +/// rely on `Auto` and let the spec decide. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AuthStrategy { + /// Default: derive the strategy from the spec. If any operation + /// declares per-endpoint `security:`, use [`Routing`](Self::Routing); + /// otherwise use [`Any`](Self::Any). Matches the behaviour from before + /// `auth_strategy()` existed. + #[default] + Auto, + /// Try each scheme in registration order; first one with credentials + /// applies. The "any of" semantics — common when an API accepts + /// multiple equivalent auth methods (e.g., bearer or API key). + Any, + /// Apply *every* scheme to every request. The "and" semantics — used + /// when an API requires multiple schemes simultaneously (e.g., HMAC + /// signature plus an API key). + All, + /// Per-endpoint dispatch via the operation's `security_requirements`. + /// Falls back to an [`AnyAuthProvider`] over the bound schemes for + /// operations that didn't declare requirements. If the spec has no + /// per-endpoint security at all, this behaves identically to `Any`. + Routing, +} + +/// How a builder caller has bound credentials to a scheme name. +#[derive(Clone)] +pub enum SchemeBinding { + /// Single-value source — bearer / apiKey / oauth2 schemes. + Token(AuthCredentialSource), + /// Two-value source — http basic. Both must resolve for the provider + /// to claim credentials. + Basic { + username: AuthCredentialSource, + password: AuthCredentialSource, + }, + /// Caller built their own provider. Used as-is. Bypasses the + /// spec→provider lowering, so the binding's `name` is purely a routing + /// key into [`RoutingAuthProvider`]. + Custom(DynAuthProvider), +} + +impl std::fmt::Debug for SchemeBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SchemeBinding::Token(s) => f.debug_tuple("Token").field(s).finish(), + SchemeBinding::Basic { .. } => f.write_str("Basic { .. }"), + SchemeBinding::Custom(p) => write!(f, "Custom({})", p.name()), + } + } +} + +impl SchemeBinding { + /// Walk the binding's credential sources for every CLI arg name they + /// reference. CliApp uses this before clap parsing to register the + /// corresponding global `--` flags. `Custom` bindings are opaque + /// (the user owns the provider) so they contribute nothing here. + pub fn cli_args(&self) -> Vec<&str> { + match self { + SchemeBinding::Token(src) => src.cli_args(), + SchemeBinding::Basic { username, password } => { + let mut out = username.cli_args(); + out.extend(password.cli_args()); + out + } + SchemeBinding::Custom(_) => Vec::new(), + } + } + + /// Finalize the binding's credential sources against the parsed clap + /// matches — replaces any `Cli(name)` variants with closures that read + /// from `matches`. Pass-through for `Custom` (the user already owns + /// the resolution path). + pub fn finalize(self, matches: &Arc) -> Self { + match self { + SchemeBinding::Token(src) => SchemeBinding::Token(src.finalize(matches)), + SchemeBinding::Basic { username, password } => SchemeBinding::Basic { + username: username.finalize(matches), + password: password.finalize(matches), + }, + SchemeBinding::Custom(p) => SchemeBinding::Custom(p), + } + } +} + +/// Render a human-readable "Authentication:" section for `--help` +/// describing each binding's scheme name and where it reads its value +/// from. Returns `None` when there are no bindings (caller can omit the +/// section entirely). +/// +/// The output looks like: +/// +/// ```text +/// Authentication: +/// bearerAuth API_TOKEN env var +/// apiKey --api-key flag / API_KEY env var / ~/.api/key file +/// ``` +/// +/// CLI flags and file paths are described in human terms. Closures and +/// the `Custom` binding are reported as "custom" — their source isn't +/// inspectable. +pub fn render_auth_help_section(bindings: &[(String, SchemeBinding)]) -> Option { + if bindings.is_empty() { + return None; + } + let max_name = bindings + .iter() + .map(|(n, _)| n.len()) + .max() + .unwrap_or(0) + .max(8); + + let mut out = String::from("Authentication:\n"); + for (name, binding) in bindings { + let sources = describe_binding_sources(binding); + let _ = std::fmt::Write::write_fmt( + &mut out, + format_args!(" {name: (optional)` and the block is +/// returned *without* the `Authentication:` heading so the caller can append +/// it under [`render_auth_help_section`]'s output (or supply the heading +/// itself when there are no primary bindings). +/// +/// Returns `None` when there are no layers. +pub fn render_auth_layers_help(layers: &[(String, Vec)]) -> Option { + if layers.is_empty() { + return None; + } + let max_name = layers.iter().map(|(n, _)| n.len()).max().unwrap_or(0).max(8); + let mut out = String::new(); + for (name, hints) in layers { + let sources = if hints.is_empty() { + "custom (optional)".to_string() + } else { + format!("{} (optional)", hints.join(" / ")) + }; + let _ = std::fmt::Write::write_fmt( + &mut out, + format_args!(" {name: String { + match binding { + SchemeBinding::Token(src) => describe_credential_source(src), + SchemeBinding::Basic { username, password } => { + format!( + "basic auth · username: {} · password: {}", + describe_credential_source(username), + describe_credential_source(password), + ) + } + SchemeBinding::Custom(provider) => { + let hints = provider.credential_hints(); + if hints.is_empty() { + "custom auth provider".to_string() + } else { + // credential_hints() uses "environment variable"; normalise + // to the shorter "env var" used by describe_credential_source. + hints + .iter() + .map(|h| h.replace("environment variable", "env var")) + .collect::>() + .join(" / ") + } + } + } +} + +fn describe_credential_source(src: &AuthCredentialSource) -> String { + match src { + AuthCredentialSource::Env(name) => format!("{name} env var"), + AuthCredentialSource::Cli(arg) => format!("--{arg} flag"), + AuthCredentialSource::File(path) => format!("{} file", path.display()), + AuthCredentialSource::Literal(_) => "built-in literal".to_string(), + AuthCredentialSource::Closure(_, Some(hint)) => hint.clone(), + AuthCredentialSource::Closure(_, None) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" / "), + AuthCredentialSource::Keyring { service, account } => { + format!("keyring {service}:{account}") + } + AuthCredentialSource::Missing => "(unbound)".to_string(), + } +} + +/// Walk every binding in `bindings` and collect the union of CLI arg +/// names they reference. Deduplicated while preserving first-seen order. +pub fn collect_binding_cli_args(bindings: &[(String, SchemeBinding)]) -> Vec { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out: Vec = Vec::new(); + for (_, b) in bindings { + for arg in b.cli_args() { + if seen.insert(arg.to_string()) { + out.push(arg.to_string()); + } + } + } + out +} + +/// Finalize every binding against `matches`. Returns a new `Vec`; the +/// originals are consumed. +pub fn finalize_bindings( + bindings: Vec<(String, SchemeBinding)>, + matches: &Arc, +) -> Vec<(String, SchemeBinding)> { + bindings + .into_iter() + .map(|(name, b)| (name, b.finalize(matches))) + .collect() +} + +/// Lower a single binding to a concrete provider, given the spec scheme +/// declaration that names it (or `None` if the binding references a scheme +/// not declared in `components.securitySchemes`). +/// +/// Undeclared schemes (`declared == None`) default to bearer for token +/// bindings and basic for two-value bindings — sensible defaults for +/// callers who don't have a spec to lean on (e.g., GraphQL CLIs). +/// +/// When a binding shape doesn't match its declared scheme (e.g., a Token +/// bound to `HttpBasic`), the binding is dropped with a `tracing::warn!` +/// so the misconfiguration shows up in the structured logs rather than +/// silently sending requests with no auth. +fn provider_for_binding( + scheme_name: &str, + binding: &SchemeBinding, + declared: Option<&crate::openapi::discovery::SecurityScheme>, +) -> Option { + use crate::openapi::discovery::SecurityScheme as S; + match binding { + SchemeBinding::Custom(p) => Some(p.clone()), + SchemeBinding::Token(source) => match declared { + // Bearer/OAuth2 → standard Authorization: Bearer . + // Undeclared schemes default to bearer (legacy parity). + Some(S::HttpBearer) | Some(S::OAuth2) | None => Some(Arc::new( + BearerAuthProvider::new(scheme_name, source.clone()), + )), + Some(S::ApiKeyHeader { name }) => Some(Arc::new(HeaderAuthProvider::new( + scheme_name, + name, + source.clone(), + false, + ))), + Some(S::ApiKeyQuery { .. }) => { + tracing::warn!( + scheme = scheme_name, + "auth_scheme: apiKey-in-query schemes are not yet supported; binding ignored", + ); + None + } + Some(S::HttpBasic) => { + tracing::warn!( + scheme = scheme_name, + "auth_scheme: scheme is HTTP Basic but a single-value Token binding was supplied; \ + use auth_basic_scheme instead", + ); + None + } + Some(S::Other(kind)) => { + tracing::warn!( + scheme = scheme_name, + kind = kind, + "auth_scheme: unsupported scheme type; bind via auth_provider with a custom \ + provider instead", + ); + None + } + }, + SchemeBinding::Basic { username, password } => match declared { + Some(S::HttpBasic) | None => Some(Arc::new(BasicAuthProvider::new( + scheme_name, + username.clone(), + password.clone(), + ))), + _ => { + tracing::warn!( + scheme = scheme_name, + "auth_basic_scheme: scheme is not HTTP Basic; binding ignored", + ); + None + } + }, + } +} + +/// Walk a `RestDescription` and decide whether any operation declares +/// per-endpoint security requirements. Used to choose between +/// `AnyAuthProvider` (no spec-level routing needed) and +/// `RoutingAuthProvider` (some endpoints require specific schemes). +fn doc_has_per_endpoint_security(doc: &crate::openapi::discovery::RestDescription) -> bool { + fn walk(res: &crate::openapi::discovery::RestResource) -> bool { + if res + .methods + .values() + .any(|m| m.security_requirements.is_some()) + { + return true; + } + res.resources.values().any(walk) + } + doc.resources.values().any(walk) +} + +/// Protocol-agnostic provider construction. Used directly by GraphQL +/// (which has no spec-declared schemes and no per-endpoint metadata) and +/// indirectly by [`build_provider_from_doc`] for OpenAPI. +/// +/// Equivalent to [`build_provider_with_strategy`] called with +/// [`AuthStrategy::Auto`]: the strategy is derived from +/// `has_per_endpoint_security`. Use [`build_provider_with_strategy`] +/// directly if your generator wants explicit control (e.g., the all-auth +/// case the spec doesn't express). +pub fn build_provider_from_bindings( + bindings: &[(String, SchemeBinding)], + security_schemes: &HashMap, + has_per_endpoint_security: bool, +) -> DynAuthProvider { + build_provider_with_strategy( + bindings, + security_schemes, + AuthStrategy::Auto, + has_per_endpoint_security, + ) +} + +/// Strategy-aware provider construction. The fully general factory. +/// +/// Construction outline: +/// 1. Each binding is lowered to a concrete provider, using `security_schemes` +/// (if non-empty) to pick between Bearer / Header / Basic. +/// 2. Bindings are deduplicated by scheme name — last registration wins for +/// both the routing map and the AnyAuth fallback list, so the two views +/// can never disagree. +/// 3. Insertion order is preserved across the dedup so the `Any` and `All` +/// strategies see schemes in registration order. +/// 4. The `strategy` chooses how the lowered providers compose: +/// - `Auto` → `Routing` if `has_per_endpoint_security`, else `Any`. +/// - `Any` → `AnyAuthProvider`. First with credentials applies. +/// - `All` → `AllAuthProvider`. Every scheme applies, every request. +/// - `Routing` → `RoutingAuthProvider` with `AnyAuthProvider` as default. +/// 5. With no bindings at all, returns a [`NoAuthProvider`] sentinel — +/// independent of `strategy`. `All` / `Routing` with zero bindings would +/// otherwise produce a degenerate composite (an empty `AllAuthProvider` +/// that vacuously claims credentials, or a `RoutingAuthProvider` whose +/// only contribution is its default fallback). Collapsing to +/// `NoAuthProvider` keeps the unauthenticated-CLI case unambiguous. +pub fn build_provider_with_strategy( + bindings: &[(String, SchemeBinding)], + security_schemes: &HashMap, + strategy: AuthStrategy, + has_per_endpoint_security: bool, +) -> DynAuthProvider { + if bindings.is_empty() { + return Arc::new(NoAuthProvider); + } + + let mut by_name: HashMap = HashMap::new(); + let mut order: Vec = Vec::new(); + for (name, binding) in bindings { + let declared = security_schemes.get(name); + // Surface typos: if the spec declared *some* schemes but this + // binding's name isn't among them, the binding will silently never + // route — no operation's `security:` block can match a name that + // isn't in the registry. Don't warn when there are no declared + // schemes (that's legacy-style usage with no spec security). + if declared.is_none() && !security_schemes.is_empty() { + let declared_names: Vec<&str> = + security_schemes.keys().map(String::as_str).collect(); + tracing::warn!( + scheme = name.as_str(), + declared = ?declared_names, + "auth scheme name is not declared in components.securitySchemes; \ + check for typos — operations referencing a different name won't \ + receive this credential", + ); + } + let Some(provider) = provider_for_binding(name, binding, declared) else { + continue; + }; + if !by_name.contains_key(name) { + order.push(name.clone()); + } + by_name.insert(name.clone(), provider); + } + + let ordered: Vec = order.iter().map(|n| by_name[n].clone()).collect(); + + let resolved = match strategy { + AuthStrategy::Auto => { + if has_per_endpoint_security { + AuthStrategy::Routing + } else { + AuthStrategy::Any + } + } + explicit => explicit, + }; + + match resolved { + AuthStrategy::Auto => unreachable!("Auto resolved above"), + AuthStrategy::Any => Arc::new(AnyAuthProvider::new(ordered)), + AuthStrategy::All => Arc::new(AllAuthProvider::new(ordered)), + AuthStrategy::Routing => { + // The default for unspecified endpoints is still AnyAuth over + // all schemes — preserves the "use whatever works" fallback + // for operations the spec didn't pin. + let any: DynAuthProvider = Arc::new(AnyAuthProvider::new(ordered)); + Arc::new(RoutingAuthProvider::new(by_name).with_default(any)) + } + } +} + +/// OpenAPI-flavored convenience: pulls `security_schemes` and the +/// per-endpoint flag out of a parsed [`RestDescription`][rd]. +/// +/// [rd]: crate::openapi::discovery::RestDescription +pub fn build_provider_from_doc( + doc: &crate::openapi::discovery::RestDescription, + bindings: &[(String, SchemeBinding)], +) -> DynAuthProvider { + build_provider_from_bindings( + bindings, + &doc.security_schemes, + doc_has_per_endpoint_security(doc), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::provider::EndpointAuthMetadata; + use crate::auth::test_helpers::{auth_header, header, req}; + + fn doc_with_schemes( + schemes: &[(&str, crate::openapi::discovery::SecurityScheme)], + ) -> crate::openapi::discovery::RestDescription { + let mut d = crate::openapi::discovery::RestDescription::default(); + for (name, scheme) in schemes { + d.security_schemes + .insert((*name).to_string(), scheme.clone()); + } + d + } + + fn doc_with_method_requirement( + schemes: &[(&str, crate::openapi::discovery::SecurityScheme)], + requirement: HashMap>, + ) -> crate::openapi::discovery::RestDescription { + let mut d = doc_with_schemes(schemes); + let method = crate::openapi::discovery::RestMethod { + security_requirements: Some(vec![requirement]), + ..Default::default() + }; + let mut resource = crate::openapi::discovery::RestResource::default(); + resource.methods.insert("op".to_string(), method); + d.resources.insert("group".to_string(), resource); + d + } + + #[test] + fn no_bindings_returns_noop() { + let doc = crate::openapi::discovery::RestDescription::default(); + let p = build_provider_from_doc(&doc, &[]); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn bearer_scheme_routes_to_bearer_provider() { + let doc = doc_with_schemes(&[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )]); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert_eq!(p.name(), "any"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn apikey_header_uses_declared_header_name() { + let doc = doc_with_schemes(&[( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + )]); + let bindings = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("k")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn basic_scheme_routes_to_basic_provider() { + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::literal("alice"), + password: AuthCredentialSource::literal("hunter2"), + }, + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YWxpY2U6aHVudGVyMg=="), + ); + } + + #[test] + fn token_binding_for_basic_scheme_is_skipped() { + // Token form can't satisfy HttpBasic (which needs two values). + // The binding should be silently dropped — provider has no creds. + let doc = doc_with_schemes(&[( + "basic", + crate::openapi::discovery::SecurityScheme::HttpBasic, + )]); + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("oops")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn uses_routing_when_doc_has_per_endpoint_security() { + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[ + ( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + ), + ( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ), + ], + req_map.clone(), + ); + let bindings = vec![ + ( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("k")), + ), + ]; + let p = build_provider_from_doc(&doc, &bindings); + assert_eq!(p.name(), "routing"); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_map]); + let r = p.apply(req(), &endpoint).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + assert!(built.headers().get("authorization").is_none()); + } + + #[tokio::test] + async fn routing_falls_back_to_any_for_unspecified_endpoint() { + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearerAuth", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + )]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn duplicate_binding_uses_last_write_consistently() { + // Two bindings to the same scheme name. The user almost certainly + // didn't mean it, but if it happens, both the AnyAuth fallback and + // the RoutingAuth map must agree on which provider wins. + let mut req_map = HashMap::new(); + req_map.insert("apiKey".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "apiKey", + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + )], + req_map.clone(), + ); + let bindings = vec![ + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("first")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("second")), + ), + ]; + let p = build_provider_from_doc(&doc, &bindings); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_map]); + let r = p.apply(req(), &endpoint).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("second")); + let r2 = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r2, "x-api-key").as_deref(), Some("second")); + } + + #[tokio::test] + async fn from_bindings_works_without_doc() { + // GraphQL path: no security_schemes registry, no per-endpoint + // metadata, but the same builder API. Should still produce a + // working AnyAuthProvider. + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("g")), + )]; + let p = build_provider_from_bindings(&bindings, &HashMap::new(), false); + assert_eq!(p.name(), "any"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer g")); + } + + #[tokio::test] + async fn strategy_all_applies_every_scheme_unconditionally() { + // Generator knows the API requires bearer AND apiKey on every + // request. Spec might not express this; the strategy override + // does. + let bindings = vec![ + ( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("tok")), + ), + ( + "apiKey".to_string(), + SchemeBinding::Custom(crate::auth::test_helpers::api_key( + "apiKey", + "X-Api-Key", + "k", + )), + ), + ]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::All, + false, + ); + assert_eq!(p.name(), "all"); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[test] + fn strategy_any_overrides_spec_routing() { + // Spec has per-endpoint security (would auto-pick Routing), but + // the generator forces Any anyway. Verifies the override actually + // wins. + let mut req_map = HashMap::new(); + req_map.insert("bearer".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearer", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &doc.security_schemes, + AuthStrategy::Any, + true, // doc has per-endpoint security, but Any wins + ); + assert_eq!(p.name(), "any"); + } + + #[test] + fn strategy_routing_used_even_without_per_endpoint_security() { + // Generator wants routing semantics regardless of what the spec + // says. Falls back to AnyAuthProvider default for any op without + // requirements. + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::Routing, + false, // no per-endpoint security in the spec + ); + assert_eq!(p.name(), "routing"); + } + + #[test] + fn strategy_auto_picks_routing_when_spec_has_per_endpoint_security() { + let mut req_map = HashMap::new(); + req_map.insert("bearer".to_string(), Vec::::new()); + let doc = doc_with_method_requirement( + &[( + "bearer", + crate::openapi::discovery::SecurityScheme::HttpBearer, + )], + req_map, + ); + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = + build_provider_with_strategy(&bindings, &doc.security_schemes, AuthStrategy::Auto, true); + assert_eq!(p.name(), "routing"); + } + + #[test] + fn strategy_routing_with_zero_bindings_returns_no_auth() { + // Explicit Routing strategy + no bindings collapses to NoAuthProvider. + // Confirms the early-return at the top of build_provider_with_strategy + // applies regardless of `strategy` — a Routing wrapper around zero + // schemes would have only its (also empty) default to fall back on, + // which isn't a useful state to expose. + let p = build_provider_with_strategy( + &[], + &HashMap::new(), + AuthStrategy::Routing, + true, + ); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[test] + fn strategy_all_with_zero_bindings_returns_no_auth() { + // Same contract for All. An empty AllAuthProvider would vacuously + // claim no credentials anyway, but collapsing to NoAuthProvider + // keeps the unauthenticated case uniform across strategies. + let p = build_provider_with_strategy( + &[], + &HashMap::new(), + AuthStrategy::All, + false, + ); + assert_eq!(p.name(), "none"); + assert!(!p.has_credentials()); + } + + #[test] + fn strategy_auto_picks_any_when_no_per_endpoint_security() { + let bindings = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::literal("t")), + )]; + let p = build_provider_with_strategy( + &bindings, + &HashMap::new(), + AuthStrategy::Auto, + false, + ); + assert_eq!(p.name(), "any"); + } + + // -------- render_auth_help_section -------- + + #[test] + fn render_auth_help_section_none_for_empty_bindings() { + assert!(render_auth_help_section(&[]).is_none()); + } + + #[test] + fn render_auth_help_section_describes_env_var() { + let bindings = vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("API_TOKEN")), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("Authentication:")); + assert!(out.contains("bearerAuth")); + assert!(out.contains("API_TOKEN env var")); + } + + #[test] + fn render_auth_help_section_describes_chain() { + let bindings = vec![( + "apiKey".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-key"), + AuthCredentialSource::from_env("API_KEY"), + AuthCredentialSource::file("~/.api/key"), + ])), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("--api-key flag")); + assert!(out.contains("API_KEY env var")); + assert!(out.contains("~/.api/key file")); + assert!(out.contains(" / ")); + } + + #[test] + fn render_auth_help_section_describes_basic_pair() { + let bindings = vec![( + "basic".to_string(), + SchemeBinding::Basic { + username: AuthCredentialSource::from_env("API_USER"), + password: AuthCredentialSource::from_env("API_PASS"), + }, + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("basic")); + assert!(out.contains("username")); + assert!(out.contains("password")); + assert!(out.contains("API_USER env var")); + assert!(out.contains("API_PASS env var")); + } + + #[test] + fn render_auth_layers_help_none_for_empty() { + assert!(render_auth_layers_help(&[]).is_none()); + } + + #[test] + fn render_auth_layers_help_marks_optional_with_hints() { + let layers = vec![( + "sandboxAuthorization".to_string(), + vec!["SANDBOXES_TOKEN environment variable".to_string()], + )]; + let out = render_auth_layers_help(&layers).unwrap(); + assert!(out.contains("sandboxAuthorization")); + assert!(out.contains("SANDBOXES_TOKEN environment variable")); + assert!(out.contains("(optional)")); + } + + #[test] + fn render_auth_layers_help_handles_hintless_layer() { + let layers = vec![("x".to_string(), Vec::new())]; + let out = render_auth_layers_help(&layers).unwrap(); + assert!(out.contains("custom (optional)")); + } + + #[test] + fn render_auth_help_section_marks_hintless_custom_provider_opaque() { + let bindings = vec![( + "x".to_string(), + SchemeBinding::Custom(crate::auth::test_helpers::bearer("x", "tok")), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("custom auth provider")); + } + + #[test] + fn render_auth_help_section_shows_custom_provider_credential_hints() { + use crate::auth::schemes::BasicAuthProvider; + let provider: DynAuthProvider = Arc::new(BasicAuthProvider::username_only( + "ApiKeyAuth", + AuthCredentialSource::from_env("CLOSE_API_KEY"), + )); + let bindings = vec![( + "ApiKeyAuth".to_string(), + SchemeBinding::Custom(provider), + )]; + let out = render_auth_help_section(&bindings).unwrap(); + assert!(out.contains("CLOSE_API_KEY env var"), "should show env var name with short label, got: {out}"); + assert!(!out.contains("environment variable"), "should use 'env var' not 'environment variable', got: {out}"); + assert!(!out.contains("custom auth provider"), "should not show opaque label, got: {out}"); + } + + #[tokio::test] + async fn custom_binding_used_as_is() { + let custom: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "custom", + "X-Custom", + AuthCredentialSource::literal("c"), + false, + )); + let doc = crate::openapi::discovery::RestDescription::default(); + let bindings = vec![("custom".to_string(), SchemeBinding::Custom(custom))]; + let p = build_provider_from_doc(&doc, &bindings); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-custom").as_deref(), Some("c")); + } +} diff --git a/src/auth/compose.rs b/src/auth/compose.rs new file mode 100644 index 0000000..3d5c963 --- /dev/null +++ b/src/auth/compose.rs @@ -0,0 +1,815 @@ +//! Composition wrappers: [`AnyAuthProvider`] (OR semantics) and +//! [`RoutingAuthProvider`] (per-endpoint dispatch via the operation's +//! `security_requirements`). + +use std::collections::HashMap; + +use crate::auth::provider::{AuthProvider, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// AnyAuthProvider — OR semantics. +// --------------------------------------------------------------------------- + +/// Try each child provider in order. The first one with credentials applies +/// its headers and the wrapper returns. If no child has credentials, the +/// request goes out unauthenticated. +/// +/// Mirrors the TS `AnyAuthProvider`. Used when the CLI declares multiple +/// schemes but the OpenAPI operations don't pin one per endpoint. +#[derive(Debug, Clone)] +pub struct AnyAuthProvider { + name: String, + providers: Vec, +} + +impl AnyAuthProvider { + pub fn new(providers: Vec) -> Self { + Self { + name: "any".to_string(), + providers, + } + } +} + +impl AuthProvider for AnyAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.providers.iter().any(|p| p.has_credentials()) + } + + fn inject_token_cache(&self, cli_name: &str) { + for p in &self.providers { + p.inject_token_cache(cli_name); + } + } + + fn credential_hints(&self) -> Vec { + self.providers + .iter() + .flat_map(|p| p.credential_hints()) + .collect() + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + self.providers + .iter() + .any(|p| p.has_credentials_for(endpoint)) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Endpoint-aware filter: lets nested `RoutingAuthProvider` children + // tell us they can't satisfy *this* endpoint even though they have + // credentials for some scheme. Leaf providers (Bearer/Basic/Header) + // ignore the endpoint, so this degenerates to `has_credentials()` + // for them. + for provider in &self.providers { + if provider.has_credentials_for(endpoint) { + return provider.apply(request, endpoint); + } + } + Ok(request) + } +} + +// --------------------------------------------------------------------------- +// AllAuthProvider — AND semantics. Every scheme is applied to every request. +// --------------------------------------------------------------------------- + +/// Apply *every* child provider's headers to the request, in registration +/// order. The "all auth" strategy: when an API requires multiple schemes +/// simultaneously on every operation (e.g., `Authorization: Bearer X` AND +/// `X-Api-Key: Y`), and the spec doesn't express that via per-operation +/// security blocks. +/// +/// `has_credentials()` is `true` only when *all* children have credentials — +/// the request can't be satisfied otherwise. If a child fails to apply +/// (e.g., malformed token bytes), the error short-circuits. +#[derive(Debug, Clone)] +pub struct AllAuthProvider { + name: String, + providers: Vec, +} + +impl AllAuthProvider { + pub fn new(providers: Vec) -> Self { + Self { + name: "all".to_string(), + providers, + } + } +} + +impl AuthProvider for AllAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + // All-auth means every scheme must contribute. If any is missing, + // the request can't be authenticated as the API requires. + !self.providers.is_empty() && self.providers.iter().all(|p| p.has_credentials()) + } + + fn inject_token_cache(&self, cli_name: &str) { + for p in &self.providers { + p.inject_token_cache(cli_name); + } + } + + fn credential_hints(&self) -> Vec { + self.providers + .iter() + .flat_map(|p| p.credential_hints()) + .collect() + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + !self.providers.is_empty() + && self + .providers + .iter() + .all(|p| p.has_credentials_for(endpoint)) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Short-circuit when the requirement can't be fully satisfied. The + // all-auth contract is "every scheme contributes"; sending a partial + // request with only some headers attached would let the request hit + // the wire half-authed and leak whichever bound credentials we do + // have. The friendly-error path catches this on the response side, + // but pre-emptively dropping the headers keeps stray tokens off the + // wire too. + if !self.has_credentials_for(endpoint) { + return Ok(request); + } + let mut req = request; + for provider in &self.providers { + req = provider.apply(req, endpoint)?; + } + Ok(req) + } +} + +// --------------------------------------------------------------------------- +// LayeredAuthProvider — a primary scheme plus optional additive headers. +// --------------------------------------------------------------------------- + +/// Wrap a `primary` provider and layer zero or more *optional* providers on +/// top of it. The layers are additive supplements, not alternatives: every +/// layer that currently has credentials is applied to the request in addition +/// to whatever the primary attaches. +/// +/// Unlike [`AllAuthProvider`], a layer never makes the request mandatory — +/// satisfiability (`has_credentials` / `has_credentials_for`) is decided by +/// the primary alone, and a layer with no credentials is simply skipped. This +/// is the right shape for a supplementary header that sits *alongside* real +/// auth and is only present in some environments. +/// +/// The motivating case is Lattice Sandboxes: every request carries the normal +/// `Authorization: Bearer ` (the primary) and, only when developing +/// against a sandbox, an additional `Anduril-Sandbox-Authorization: Bearer +/// ` header (a layer). In production the layer's credential +/// is absent, so the request behaves exactly as if no layer were configured. +#[derive(Debug, Clone)] +pub struct LayeredAuthProvider { + primary: DynAuthProvider, + layers: Vec, +} + +impl LayeredAuthProvider { + pub fn new(primary: DynAuthProvider, layers: Vec) -> Self { + Self { primary, layers } + } +} + +impl AuthProvider for LayeredAuthProvider { + fn name(&self) -> &str { + self.primary.name() + } + + fn has_credentials(&self) -> bool { + // Optional layers don't gate satisfiability — the primary decides + // whether the CLI can authenticate at all. + self.primary.has_credentials() + } + + fn inject_token_cache(&self, cli_name: &str) { + self.primary.inject_token_cache(cli_name); + for layer in &self.layers { + layer.inject_token_cache(cli_name); + } + } + + fn credential_hints(&self) -> Vec { + // Surface only the primary's hints in the friendly auth-error path: + // a missing optional layer (e.g. no sandbox token in production) is + // not a misconfiguration and shouldn't be reported as a missing + // credential. + self.primary.credential_hints() + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + self.primary.has_credentials_for(endpoint) + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + // Explicitly anonymous endpoints (`security: []`) opt out of auth + // entirely. The executor already short-circuits these before calling + // `apply`, but guard here too so a supplementary header can never + // leak onto an opt-out endpoint regardless of the call path. + if endpoint.is_explicit_anonymous() { + return Ok(request); + } + let mut req = self.primary.apply(request, endpoint)?; + for layer in &self.layers { + if layer.has_credentials() { + req = layer.apply(req, endpoint)?; + } + } + Ok(req) + } +} + +// --------------------------------------------------------------------------- +// RoutingAuthProvider — per-endpoint security map. +// --------------------------------------------------------------------------- + +/// Dispatch by the endpoint's security requirements. The OpenAPI `security` +/// field is an OR of ANDs: `[{schemeA: []}, {schemeB: [], schemeC: []}]` +/// means "schemeA alone, OR (schemeB AND schemeC)". +/// +/// At call time: +/// 1. If the endpoint has no requirements, the wrapper falls through to its +/// `default` policy (typically an [`AnyAuthProvider`]) so unauthenticated +/// operations stay unauthenticated and unlabeled operations still get a +/// sensible default header. +/// 2. Otherwise, find the first requirement whose every scheme has a +/// registered provider with credentials, and apply each provider in turn +/// (their headers compose). +/// 3. If no requirement is satisfiable, return the request unchanged. The +/// server will respond 401/403 and `handle_error_response` +/// will surface a helpful "no credentials configured" message. +#[derive(Debug, Clone)] +pub struct RoutingAuthProvider { + name: String, + schemes: HashMap, + /// Fallback for endpoints with no `security` declared. Typically an + /// [`AnyAuthProvider`] over all configured schemes. + default: Option, +} + +impl RoutingAuthProvider { + pub fn new(schemes: HashMap) -> Self { + Self { + name: "routing".to_string(), + schemes, + default: None, + } + } + + pub fn with_default(mut self, default: DynAuthProvider) -> Self { + self.default = Some(default); + self + } +} + +impl AuthProvider for RoutingAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.schemes.values().any(|p| p.has_credentials()) + || self.default.as_ref().is_some_and(|p| p.has_credentials()) + } + + fn inject_token_cache(&self, cli_name: &str) { + for p in self.schemes.values() { + p.inject_token_cache(cli_name); + } + if let Some(d) = &self.default { + d.inject_token_cache(cli_name); + } + } + + fn credential_hints(&self) -> Vec { + let mut hints: Vec = self + .schemes + .values() + .flat_map(|p| p.credential_hints()) + .collect(); + if let Some(d) = &self.default { + hints.extend(d.credential_hints()); + } + hints + } + + /// Endpoint-aware credential check. + /// + /// - **No requirements declared**: defer to the wrapper's `default` + /// (typically an `AnyAuthProvider`), which decides based on its own + /// children. If there's no default, fall back to `has_credentials()` + /// over our schemes — that's the closest we can get. + /// - **Explicit anonymous (`security: []`)**: the endpoint doesn't need + /// auth, so report `true` to suppress the friendly "no creds" message + /// on a 401 — that response would be a server-side mismatch, not a + /// user config issue. + /// - **Concrete requirements**: report whether any requirement's + /// schemes are all bound *and* hold credentials — same predicate + /// `apply` uses to find a satisfiable requirement. If yes, we + /// attached headers; if no, the 401 is the user's missing-creds + /// problem and the friendly error fires. + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + match &endpoint.security_requirements { + None => match &self.default { + Some(d) => d.has_credentials_for(endpoint), + None => self.has_credentials(), + }, + Some(reqs) if reqs.is_empty() => true, + Some(reqs) => reqs.iter().any(|req| { + req.keys().all(|name| { + self.schemes + .get(name) + .is_some_and(|p| p.has_credentials()) + }) + }), + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + let requirements = match &endpoint.security_requirements { + // Operation didn't pin a policy: defer to the default. + None => { + return match &self.default { + Some(d) => d.apply(request, endpoint), + None => Ok(request), + }; + } + // `security: []` — explicit anonymous, attach nothing. + Some(reqs) if reqs.is_empty() => return Ok(request), + Some(reqs) => reqs, + }; + + let satisfiable = requirements.iter().find(|req| { + req.keys().all(|name| { + self.schemes + .get(name) + .is_some_and(|p| p.has_credentials()) + }) + }); + + let Some(requirement) = satisfiable else { + // No declared requirement is satisfiable. Diverges from the TS + // generator (which throws): we let the request go out unauthed + // so the server's 401/403 + `handle_error_response` + // can surface a friendly "no credentials configured" message. + return Ok(request); + }; + + let mut req = request; + // Sort the requirement's scheme names so multi-scheme requirements + // apply in a stable order regardless of `HashMap` iteration. Each + // provider sets a distinct header so order doesn't affect the wire + // payload, but reproducibility matters for tracing and snapshot + // tests. + let mut scheme_names: Vec<&String> = requirement.keys().collect(); + scheme_names.sort(); + for scheme_name in scheme_names { + // Safe: `satisfiable` filtered to requirements where every key + // has a registered provider. + let provider = &self.schemes[scheme_name]; + req = provider.apply(req, endpoint)?; + } + Ok(req) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use crate::auth::credential::AuthCredentialSource; + use crate::auth::schemes::{BearerAuthProvider, HeaderAuthProvider}; + use crate::auth::test_helpers::{api_key, auth_header, bearer, header, req}; + + // -------- AnyAuthProvider -------- + + #[tokio::test] + async fn any_auth_picks_first_with_credentials() { + let a: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "a", + AuthCredentialSource::Missing, + )); + let b: DynAuthProvider = api_key("b", "X-Api-Key", "k"); + let any = AnyAuthProvider::new(vec![a, b]); + assert!(any.has_credentials()); + let r = any.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn any_auth_skips_routing_child_that_cant_satisfy_endpoint() { + // Nested-composition guard: an `AnyAuthProvider` whose first child + // is a `RoutingAuthProvider` that *has some credentials* but can't + // satisfy this specific endpoint must fall through to the next + // child rather than calling apply on the routing child. + // + // Without the endpoint-aware filter, the first child's + // `has_credentials()` returns true and `apply` short-circuits — even + // though that child would attach nothing to the request. + let mut routing_schemes: HashMap = HashMap::new(); + routing_schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + let routing_child: DynAuthProvider = std::sync::Arc::new( + RoutingAuthProvider::new(routing_schemes), + ); + let bearer_child: DynAuthProvider = bearer("bearer", "tok"); + let any = AnyAuthProvider::new(vec![routing_child, bearer_child]); + + // Endpoint demands `bearer`; routing child only has `apiKey`. + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + + let r = any.apply(req(), &endpoint).unwrap(); + // Bearer should have been attached by the second child. + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn any_auth_no_credentials_is_passthrough() { + let any = AnyAuthProvider::new(vec![Arc::new(BearerAuthProvider::new( + "x", + AuthCredentialSource::Missing, + ))]); + assert!(!any.has_credentials()); + let r = any.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + } + + // -------- AllAuthProvider -------- + + #[tokio::test] + async fn all_auth_applies_every_provider() { + let a: DynAuthProvider = bearer("a", "tok"); + let b: DynAuthProvider = api_key("b", "X-Api-Key", "k"); + let all = AllAuthProvider::new(vec![a, b]); + assert!(all.has_credentials()); + let r = all.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[test] + fn all_auth_has_credentials_requires_every_child() { + let a: DynAuthProvider = bearer("a", "tok"); + let b: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "b", + AuthCredentialSource::Missing, + )); + let all = AllAuthProvider::new(vec![a, b]); + // One missing → all auth can't be satisfied. + assert!(!all.has_credentials()); + } + + #[test] + fn all_auth_empty_provider_list_is_no_credentials() { + // Vacuous truth would say "all of zero providers have creds = true", + // but for the all-auth strategy that's misleading: no providers + // means no auth gets attached, which isn't what the user asked for. + let all = AllAuthProvider::new(Vec::new()); + assert!(!all.has_credentials()); + } + + #[tokio::test] + async fn all_auth_short_circuits_on_provider_error() { + // Bearer with a token containing CTL chars errors in apply. + let bad: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bad", + AuthCredentialSource::literal("bad\ntoken"), + )); + let good: DynAuthProvider = api_key("good", "X-Api-Key", "k"); + // Order matters: bad first → error before good ever runs. + let all = AllAuthProvider::new(vec![bad, good]); + let err = all + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + // -------- LayeredAuthProvider -------- + + #[tokio::test] + async fn layered_applies_primary_and_present_layer() { + let primary: DynAuthProvider = bearer("bearer", "tok"); + let layer: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "sandbox", + "Anduril-Sandbox-Authorization", + AuthCredentialSource::literal("sbx"), + true, + )); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built + .headers() + .get("anduril-sandbox-authorization") + .and_then(|v| v.to_str().ok()), + Some("Bearer sbx"), + ); + } + + #[tokio::test] + async fn layered_skips_layer_without_credentials() { + // Production case: no sandbox token set. The layer is silently + // skipped and the request behaves as if it weren't configured. + let primary: DynAuthProvider = bearer("bearer", "tok"); + let layer: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "sandbox", + "Anduril-Sandbox-Authorization", + AuthCredentialSource::Missing, + true, + )); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + let built = r.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert!(built.headers().get("anduril-sandbox-authorization").is_none()); + } + + #[test] + fn layered_satisfiability_follows_primary_only() { + // A present layer must not make an otherwise-unauthenticated CLI + // claim it has credentials. + let primary: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )); + let layer: DynAuthProvider = api_key("sandbox", "Anduril-Sandbox-Authorization", "sbx"); + let p = LayeredAuthProvider::new(primary, vec![layer]); + assert!(!p.has_credentials()); + assert_eq!(p.name(), "bearer"); + } + + #[test] + fn layered_credential_hints_exclude_layers() { + // The friendly auth-error path should point at the primary's source, + // not nag about an optional layer that's missing by design. + let primary: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::from_env("ENVIRONMENT_TOKEN"), + )); + let layer: DynAuthProvider = Arc::new(HeaderAuthProvider::new( + "sandbox", + "Anduril-Sandbox-Authorization", + AuthCredentialSource::from_env("SANDBOXES_TOKEN"), + true, + )); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let hints = p.credential_hints(); + assert_eq!(hints, vec!["ENVIRONMENT_TOKEN environment variable"]); + } + + #[tokio::test] + async fn layered_does_not_attach_to_explicit_anonymous_endpoint() { + // `security: []` opts the operation out of auth entirely — neither + // the primary nor the supplementary layer should be attached. + let primary: DynAuthProvider = bearer("bearer", "tok"); + let layer: DynAuthProvider = api_key("sandbox", "Anduril-Sandbox-Authorization", "sbx"); + let p = LayeredAuthProvider::new(primary, vec![layer]); + let r = p + .apply(req(), &EndpointAuthMetadata::explicit_anonymous()) + .unwrap(); + let built = r.build().unwrap(); + assert!(built.headers().get("anduril-sandbox-authorization").is_none()); + } + + // -------- RoutingAuthProvider -------- + + fn routing_setup() -> RoutingAuthProvider { + let mut schemes: HashMap = HashMap::new(); + schemes.insert("bearer".to_string(), bearer("bearer", "tok")); + schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + RoutingAuthProvider::new(schemes) + } + + #[tokio::test] + async fn routing_unspecified_no_default_is_passthrough() { + let r = routing_setup(); + assert!(r.has_credentials()); + let out = r + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(out), None); + } + + #[tokio::test] + async fn routing_explicit_anonymous_skips_auth_even_with_default() { + // `security: []` on the operation means the endpoint is explicitly + // unauthenticated. Even with a default provider that would happily + // attach a bearer header, the routing wrapper must respect the + // operation's opt-out. + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + let out = routing + .apply(req(), &EndpointAuthMetadata::explicit_anonymous()) + .unwrap(); + assert_eq!(auth_header(out), None); + } + + #[tokio::test] + async fn routing_picks_satisfiable_requirement() { + let routing = routing_setup(); + let mut req_a = HashMap::new(); + req_a.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req_a]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(header(out, "x-api-key").as_deref(), Some("k")); + } + + #[tokio::test] + async fn routing_falls_back_to_or_alternative() { + let routing = routing_setup(); + let mut req1 = HashMap::new(); + req1.insert("nonexistent".to_string(), Vec::::new()); + let mut req2 = HashMap::new(); + req2.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req1, req2]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(auth_header(out).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn routing_combines_anded_schemes_in_one_requirement() { + let routing = routing_setup(); + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + requirement.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + let out = routing.apply(req(), &endpoint).unwrap(); + let built = out.build().unwrap(); + assert_eq!( + built.headers().get("authorization").and_then(|v| v.to_str().ok()), + Some("Bearer tok"), + ); + assert_eq!( + built.headers().get("x-api-key").and_then(|v| v.to_str().ok()), + Some("k"), + ); + } + + #[tokio::test] + async fn routing_uses_default_when_endpoint_has_no_requirements() { + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + let out = routing + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(out).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn routing_no_satisfiable_requirement_is_passthrough() { + let mut schemes: HashMap = HashMap::new(); + schemes.insert( + "bearer".to_string(), + Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )), + ); + let routing = RoutingAuthProvider::new(schemes); + let mut requirement = HashMap::new(); + requirement.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![requirement]); + let out = routing.apply(req(), &endpoint).unwrap(); + assert_eq!(auth_header(out), None); + } + + // -------- has_credentials_for(endpoint) -------- + + #[test] + fn routing_has_credentials_for_unspecified_with_no_default_uses_general_check() { + let r = routing_setup(); + // No default → falls back to has_credentials() over schemes. + assert!(r.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + #[test] + fn routing_has_credentials_for_explicit_anonymous_is_true() { + // `security: []` means "no creds needed" — report true so a 401 + // doesn't trigger the friendly "no creds" message (the response + // would be a server-side mismatch, not a user config issue). + let r = routing_setup(); + assert!(r.has_credentials_for(&EndpointAuthMetadata::explicit_anonymous())); + } + + #[test] + fn routing_has_credentials_for_satisfiable_requirement_is_true() { + let r = routing_setup(); + let mut req = HashMap::new(); + req.insert("apiKey".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req]); + assert!(r.has_credentials_for(&endpoint)); + } + + #[test] + fn routing_has_credentials_for_unsatisfiable_requirement_is_false() { + // The endpoint requires `bearer` but bearer's source is Missing. + let mut schemes: HashMap = HashMap::new(); + schemes.insert( + "bearer".to_string(), + Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )), + ); + // Also bind apiKey with creds — proving has_credentials_for is + // *endpoint-aware*, not just "any scheme has creds". + schemes.insert("apiKey".to_string(), api_key("apiKey", "X-Api-Key", "k")); + let routing = RoutingAuthProvider::new(schemes); + let mut req = HashMap::new(); + req.insert("bearer".to_string(), Vec::::new()); + let endpoint = EndpointAuthMetadata::with_requirements(vec![req]); + // Even though `apiKey` has creds, the endpoint demands `bearer` — + // and bearer has none. So the friendly error path should fire. + assert!(!routing.has_credentials_for(&endpoint)); + // But the coarse `has_credentials()` returns true. This is the + // delta the new method exists to fix. + assert!(routing.has_credentials()); + } + + #[test] + fn routing_has_credentials_for_unspecified_delegates_to_default() { + // Default present + has creds → endpoint-aware check inherits. + let routing = RoutingAuthProvider::new(HashMap::new()) + .with_default(bearer("bearer", "tok")); + assert!(routing.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + #[test] + fn routing_has_credentials_for_unspecified_propagates_default_false() { + // Default present but its provider has no creds → we should + // honestly report no creds, not just "yes, a default exists". + // Pins that the delegation actually consults the default's + // predicate rather than treating "is there a default" as a yes/no. + let empty_default: DynAuthProvider = Arc::new(BearerAuthProvider::new( + "bearer", + AuthCredentialSource::Missing, + )); + let routing = RoutingAuthProvider::new(HashMap::new()).with_default(empty_default); + assert!(!routing.has_credentials_for(&EndpointAuthMetadata::unspecified())); + } + + // Sanity-check that routing_setup produces a HeaderAuthProvider with the + // expected name when looked up by scheme key — guards against an + // accidental shape change in the test helper. + #[test] + fn routing_setup_registers_provider_named_apikey() { + let r = routing_setup(); + assert_eq!(r.schemes["apiKey"].name(), "apiKey"); + assert_eq!(r.schemes["bearer"].name(), "bearer"); + // Silence dead-code lint on HeaderAuthProvider import path. + let _: &dyn AuthProvider = &HeaderAuthProvider::new( + "x", + "Y", + AuthCredentialSource::Missing, + false, + ); + } +} diff --git a/src/auth/credential.rs b/src/auth/credential.rs new file mode 100644 index 0000000..e041e12 --- /dev/null +++ b/src/auth/credential.rs @@ -0,0 +1,780 @@ +//! `AuthCredentialSource` — the lazy-supplier model for credential values. +//! +//! Mirrors the TypeScript SDK's `Supplier` and grows it into a full +//! resolution graph. Each binding holds a description of *where* its value +//! comes from — env var, CLI flag, file, literal, fallback chain, or +//! arbitrary closure — without coupling that to the auth provider that +//! consumes the resolved string. +//! +//! Resolution happens at request time so env-var changes between +//! invocations Just Work, files re-read on every call, and fallback chains +//! can mix any of the source kinds. +//! +//! # CLI flag wiring +//! +//! [`AuthCredentialSource::Cli`] holds the *name* of a clap arg — the SDK's +//! `CliApp::run_async` walks every registered binding before parsing, +//! auto-registers a global `--` flag for each `Cli` variant, and then +//! finalizes the bindings post-parse so that resolution reads from the +//! captured matches. None of this is visible to the binding's author — +//! they just write `AuthCredentialSource::cli("api-token")` and the flag +//! shows up. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use secrecy::SecretString; + +use crate::auth::keyring_store::active_store; + +type CredentialClosure = Arc Option + Send + Sync>; + +/// How an auth credential's value is resolved at request time. +#[derive(Clone)] +pub enum AuthCredentialSource { + /// Read from a process environment variable. Surrounding whitespace is + /// trimmed; returns `None` if unset, empty, or whitespace-only — + /// matching the trimming behaviour of [`File`](Self::File) so chained + /// sources behave the same regardless of which one supplies the value. + Env(String), + /// Read from a clap CLI arg. The string is the arg's *name* (clap's + /// internal id), not the `--flag` form — `cli("api-token")` corresponds + /// to a `--api-token` flag. Leading `--` / `-` are stripped for + /// convenience, so `cli("--api-token")` works too. + /// + /// Until the binding is finalized via [`finalize`](Self::finalize) (i.e. + /// before clap parses), this variant always resolves to `None` — + /// CliApp does the finalization automatically before any request runs. + Cli(String), + /// Read the contents of a file. `~` and `~/` are expanded to the + /// process's `$HOME`. Trailing whitespace is trimmed; a missing file + /// or empty content resolves to `None`. + File(PathBuf), + /// A literal value embedded at build time. + Literal(String), + /// Fallback chain. Each child is tried in order; the first to return + /// `Some` wins. Empty results count as "missing" — useful for + /// "CLI flag, then env var, then file" patterns. + Chain(Vec), + /// A user-supplied closure invoked on every request. The escape hatch + /// for any source the built-in variants don't cover (token refresh, + /// shell-out, OS keychain, etc.). + /// + /// The optional `String` carries a human-readable credential hint + /// (e.g. `"--api-token flag"`) so that `credential_hints()` can still + /// report the original source after `finalize()` replaces `Cli` with + /// a `Closure`. + Closure(CredentialClosure, Option), + /// Read from the OS keyring (or its file fallback). Populated by + /// `auth login` flows; resolves via the process-global active + /// [`KeyringStore`](crate::auth::keyring_store::KeyringStore). + /// + /// Sits at priority 3 in the default credential chain — below CLI + /// flags and env vars, above file sources (ADR-0008). + Keyring { + /// Keyring service name — typically the CLI's binary name. + service: String, + /// Account name within the service — typically the auth scheme name. + account: String, + }, + /// No source bound. The provider will report itself as unable to + /// satisfy requests. + Missing, +} + +impl AuthCredentialSource { + pub fn from_env(var_name: impl Into) -> Self { + AuthCredentialSource::Env(var_name.into()) + } + + /// Bind to a clap CLI arg. Accepts either `"api-token"` or + /// `"--api-token"` — leading dashes are stripped. + pub fn cli(arg_name: impl Into) -> Self { + let raw = arg_name.into(); + let name = raw.trim_start_matches('-').to_string(); + AuthCredentialSource::Cli(name) + } + + /// Bind to a file path. `~` and `~/` expand against `$HOME`. + pub fn file(path: impl AsRef) -> Self { + AuthCredentialSource::File(path.as_ref().to_path_buf()) + } + + pub fn literal(value: impl Into) -> Self { + AuthCredentialSource::Literal(value.into()) + } + + /// Try each source in order; the first non-empty value wins. + pub fn any(sources: impl IntoIterator) -> Self { + AuthCredentialSource::Chain(sources.into_iter().collect()) + } + + pub fn closure(f: F) -> Self + where + F: Fn() -> Option + Send + Sync + 'static, + { + AuthCredentialSource::Closure(Arc::new(f), None) + } + + /// Bind to a keyring entry at `(service, account)`. The value is read + /// from the process-global active [`KeyringStore`] at resolve time. + pub fn keyring(service: impl Into, account: impl Into) -> Self { + AuthCredentialSource::Keyring { + service: service.into(), + account: account.into(), + } + } + + /// Resolve the value, if available. Empty strings are treated as + /// missing — they would otherwise produce an empty header, which is + /// almost never what a caller intends. + /// + /// Returns a [`SecretString`] so the value can't accidentally leak via + /// `Debug`/`Display`/panic messages. Callers that need the raw `&str` + /// (to build a `HeaderValue`, base64-encode for basic auth, etc.) + /// must opt in explicitly via [`ExposeSecret::expose_secret`]. + pub fn resolve(&self) -> Option { + match self { + AuthCredentialSource::Env(name) => std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .map(SecretString::from), + AuthCredentialSource::Cli(_) => None, // resolved post-finalize + AuthCredentialSource::File(path) => read_credential_file(path), + AuthCredentialSource::Literal(v) if v.is_empty() => None, + AuthCredentialSource::Literal(v) => Some(SecretString::from(v.clone())), + AuthCredentialSource::Chain(sources) => sources.iter().find_map(|s| s.resolve()), + AuthCredentialSource::Closure(f, _) => f().filter(|v| !v.is_empty()).map(SecretString::from), + AuthCredentialSource::Keyring { service, account } => active_store() + .get(service, account) + .ok() + .flatten() + .filter(|v| !v.is_empty()) + .map(SecretString::from), + AuthCredentialSource::Missing => None, + } + } + + /// The environment-variable name backing this source, if it is an + /// [`Env`](Self::Env) source. Returns `None` for every other variant. + /// + /// Used by the OAuth2 lowering ([`OAuth2Auth`](crate::auth::OAuth2Auth)), + /// whose [`OAuth2Grant`](crate::auth::OAuth2Grant) resolves client + /// credentials from env-var *names* at token-refresh time. Non-env + /// sources can't feed that grant, so the OAuth2 path treats them as + /// missing config and fails fast rather than authenticating silently. + pub fn env_var_name(&self) -> Option<&str> { + match self { + AuthCredentialSource::Env(name) => Some(name), + _ => None, + } + } + + /// Human-readable descriptions of where this source reads credentials + /// from. Used by the auth-error path to tell the user which env var, + /// flag, or file to set. + pub fn credential_hints(&self) -> Vec { + match self { + AuthCredentialSource::Env(name) => vec![format!("{name} environment variable")], + AuthCredentialSource::Cli(arg) => vec![format!("--{arg} flag")], + AuthCredentialSource::File(path) => vec![format!("{} file", path.display())], + AuthCredentialSource::Chain(sources) => { + sources.iter().flat_map(|s| s.credential_hints()).collect() + } + AuthCredentialSource::Closure(_, Some(hint)) => vec![hint.clone()], + AuthCredentialSource::Keyring { service, account } => { + vec![format!("keyring entry {service}:{account} (populated by ` auth login`)")] + } + AuthCredentialSource::Literal(_) + | AuthCredentialSource::Closure(_, None) + | AuthCredentialSource::Missing => Vec::new(), + } + } + + /// Recursively collect every CLI arg name this source references. + /// CliApp uses this before clap parsing to register the corresponding + /// global `--` flags. + pub fn cli_args(&self) -> Vec<&str> { + let mut out = Vec::new(); + self.collect_cli_args(&mut out); + out + } + + fn collect_cli_args<'a>(&'a self, out: &mut Vec<&'a str>) { + // Enumerate every variant explicitly so adding a future variant + // (especially one that nests sources or carries an arg name) is a + // compile error rather than a silent miss. + match self { + AuthCredentialSource::Cli(name) => out.push(name.as_str()), + AuthCredentialSource::Chain(sources) => { + for s in sources { + s.collect_cli_args(out); + } + } + AuthCredentialSource::Env(_) + | AuthCredentialSource::File(_) + | AuthCredentialSource::Literal(_) + | AuthCredentialSource::Closure(_, _) + | AuthCredentialSource::Keyring { .. } + | AuthCredentialSource::Missing => {} + } + } + + /// Replace every `Cli(name)` variant in this source with a `Closure` + /// that reads the matched value out of `matches`. Called by CliApp + /// after clap parses, so that subsequent `resolve()` calls can see the + /// CLI-supplied values. + /// + /// Pass-through for non-`Cli` variants. Recurses into `Chain`. + pub fn finalize(self, matches: &Arc) -> Self { + match self { + AuthCredentialSource::Cli(name) => { + let m = Arc::clone(matches); + let hint = format!("--{name} flag"); + AuthCredentialSource::Closure( + Arc::new(move || { + m.try_get_one::(&name).ok().flatten().cloned() + }), + Some(hint), + ) + } + AuthCredentialSource::Chain(sources) => { + AuthCredentialSource::Chain( + sources.into_iter().map(|s| s.finalize(matches)).collect(), + ) + } + other => other, + } + } +} + +impl std::fmt::Debug for AuthCredentialSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthCredentialSource::Env(name) => write!(f, "Env({name})"), + AuthCredentialSource::Cli(name) => write!(f, "Cli({name})"), + AuthCredentialSource::File(path) => write!(f, "File({})", path.display()), + AuthCredentialSource::Literal(_) => write!(f, "Literal()"), + AuthCredentialSource::Chain(sources) => f.debug_tuple("Chain").field(sources).finish(), + AuthCredentialSource::Closure(_, hint) => { + if let Some(h) = hint { + write!(f, "Closure({h})") + } else { + write!(f, "Closure") + } + } + AuthCredentialSource::Keyring { service, account } => { + write!(f, "Keyring({service}:{account})") + } + AuthCredentialSource::Missing => write!(f, "Missing"), + } + } +} + +/// Read a credential file: expand `~`, trim trailing whitespace, treat +/// empty content / missing files as `None`. Result is wrapped in +/// [`SecretString`] so the file contents can't leak through Debug. +fn read_credential_file(path: &Path) -> Option { + let expanded = expand_home(path); + let raw = std::fs::read_to_string(&expanded).ok()?; + let trimmed = raw.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(SecretString::from(trimmed)) + } +} + +/// Expand a leading `~` or `~/` against the user's home directory. On +/// Unix that's `$HOME`; on Windows we fall back to `%USERPROFILE%` since +/// `$HOME` is typically unset there. Other forms (`~user`, embedded `~`) +/// are left as-is — uncommon for credential paths and surprising to +/// silently rewrite. +fn expand_home(path: &Path) -> PathBuf { + let s = match path.to_str() { + Some(s) => s, + None => return path.to_path_buf(), + }; + if s == "~" { + return home_dir().unwrap_or_else(|| path.to_path_buf()); + } + if let Some(rest) = s.strip_prefix("~/") { + if let Some(home) = home_dir() { + return home.join(rest); + } + } + path.to_path_buf() +} + +/// Cross-platform home directory lookup: `$HOME` first (set on Unix and +/// honored on Windows under WSL/MSYS shells), then `%USERPROFILE%` as the +/// native Windows fallback. +fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use secrecy::ExposeSecret; + + /// Test helper: resolve the source and expose the secret so assertions + /// can compare against plain strings. Production code should keep the + /// `SecretString` wrapper as long as possible. + fn resolved(s: &AuthCredentialSource) -> Option { + s.resolve().map(|v| v.expose_secret().to_string()) + } + + // -------- Env -------- + + #[test] + fn literal_resolves() { + assert_eq!( + resolved(&AuthCredentialSource::literal("abc")), + Some("abc".to_string()), + ); + } + + #[test] + fn env_returns_none_when_unset() { + let s = AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_DEFINITELY_UNSET"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn env_treats_empty_as_missing() { + let key = "FERN_CLI_AUTH_TEST_EMPTY"; + std::env::set_var(key, ""); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), None); + std::env::remove_var(key); + } + + #[test] + fn env_treats_whitespace_only_as_missing() { + // Parity with `File` (which trims). A whitespace-only env var would + // otherwise produce a header value of " ", which is almost never + // what the user intended and breaks `Chain` fallthrough. + let key = "FERN_CLI_AUTH_TEST_WHITESPACE"; + std::env::set_var(key, " \t \n"); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), None); + std::env::remove_var(key); + } + + #[test] + fn env_trims_surrounding_whitespace() { + let key = "FERN_CLI_AUTH_TEST_TRIM"; + std::env::set_var(key, " tok \n"); + let s = AuthCredentialSource::from_env(key); + assert_eq!(resolved(&s), Some("tok".to_string())); + std::env::remove_var(key); + } + + // -------- Closure -------- + + #[test] + fn closure_resolves() { + let s = AuthCredentialSource::closure(|| Some("zzz".to_string())); + assert_eq!(resolved(&s), Some("zzz".to_string())); + } + + #[test] + fn closure_returning_none_is_missing() { + let s = AuthCredentialSource::closure(|| None); + assert_eq!(resolved(&s), None); + } + + #[test] + fn closure_returning_empty_string_is_missing() { + let s = AuthCredentialSource::closure(|| Some(String::new())); + assert_eq!(resolved(&s), None); + } + + #[test] + fn missing_resolves_to_none() { + assert_eq!(resolved(&AuthCredentialSource::Missing), None); + } + + // -------- File -------- + + #[test] + fn file_reads_and_trims_contents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token"); + std::fs::write(&path, " my-token \n").unwrap(); + let s = AuthCredentialSource::file(&path); + assert_eq!(resolved(&s), Some("my-token".to_string())); + } + + #[test] + fn file_missing_resolves_to_none() { + let s = AuthCredentialSource::file("/definitely/not/a/real/path-xyz"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn file_empty_content_is_missing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty"); + std::fs::write(&path, " \n\n").unwrap(); + let s = AuthCredentialSource::file(&path); + assert_eq!(resolved(&s), None); + } + + #[test] + fn literal_empty_string_resolves_to_none() { + // Consistency with Env / Closure variants: empty values aren't + // sent as headers. Also makes `Chain([literal(""), env(...)])` + // fall through to the env source as a user would expect. + assert_eq!(resolved(&AuthCredentialSource::literal("")), None); + } + + #[test] + fn chain_with_empty_literal_falls_through() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::literal(""), + AuthCredentialSource::literal("backup"), + ]); + assert_eq!(resolved(&s), Some("backup".to_string())); + } + + #[test] + fn home_dir_falls_back_to_userprofile_when_home_unset() { + // Save/restore both env vars to keep test isolated. + let prev_home = std::env::var_os("HOME"); + let prev_userprofile = std::env::var_os("USERPROFILE"); + + std::env::remove_var("HOME"); + std::env::set_var("USERPROFILE", "/win-home"); + assert_eq!(home_dir(), Some(PathBuf::from("/win-home"))); + + // Restore. + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_userprofile { + Some(v) => std::env::set_var("USERPROFILE", v), + None => std::env::remove_var("USERPROFILE"), + } + } + + #[test] + fn expand_home_resolves_tilde_prefix() { + std::env::set_var("HOME", "/tmp/test-home"); + assert_eq!( + expand_home(Path::new("~/foo/bar")), + PathBuf::from("/tmp/test-home/foo/bar"), + ); + assert_eq!(expand_home(Path::new("~")), PathBuf::from("/tmp/test-home")); + // Non-tilde paths pass through. + assert_eq!( + expand_home(Path::new("/etc/passwd")), + PathBuf::from("/etc/passwd"), + ); + // Embedded ~ left alone. + assert_eq!( + expand_home(Path::new("/foo/~bar")), + PathBuf::from("/foo/~bar"), + ); + } + + // -------- Chain -------- + + #[test] + fn chain_picks_first_with_value() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::Missing, + AuthCredentialSource::literal("second"), + AuthCredentialSource::literal("third"), + ]); + assert_eq!(resolved(&s), Some("second".to_string())); + } + + #[test] + fn chain_returns_none_when_all_missing() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::Missing, + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_DEFINITELY_UNSET"), + ]); + assert_eq!(resolved(&s), None); + } + + // -------- Cli -------- + + #[test] + fn cli_strips_leading_dashes() { + match AuthCredentialSource::cli("--api-token") { + AuthCredentialSource::Cli(n) => assert_eq!(n, "api-token"), + _ => panic!("expected Cli variant"), + } + match AuthCredentialSource::cli("api-token") { + AuthCredentialSource::Cli(n) => assert_eq!(n, "api-token"), + _ => panic!("expected Cli variant"), + } + } + + #[test] + fn cli_resolves_to_none_before_finalize() { + let s = AuthCredentialSource::cli("api-token"); + assert_eq!(resolved(&s), None); + } + + #[test] + fn cli_args_collects_recursively_through_chain() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("flag-a"), + AuthCredentialSource::from_env("X"), + AuthCredentialSource::any([AuthCredentialSource::cli("flag-b")]), + ]); + let args = s.cli_args(); + assert_eq!(args, vec!["flag-a", "flag-b"]); + } + + #[test] + fn cli_args_empty_when_no_cli_variants() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::from_env("X"), + AuthCredentialSource::literal("y"), + ]); + assert!(s.cli_args().is_empty()); + } + + fn build_matches(arg_name: &'static str, value: Option<&str>) -> Arc { + let cmd = clap::Command::new("test").arg( + clap::Arg::new(arg_name) + .long(arg_name) + .num_args(1), + ); + let argv: Vec = match value { + Some(v) => vec![ + "test".to_string(), + format!("--{arg_name}"), + v.to_string(), + ], + None => vec!["test".to_string()], + }; + Arc::new(cmd.try_get_matches_from(argv).unwrap()) + } + + #[test] + fn finalize_replaces_cli_with_closure_reading_matches() { + let matches = build_matches("api-token", Some("supplied-on-cli")); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(resolved(&s), Some("supplied-on-cli".to_string())); + } + + #[test] + fn finalize_cli_returns_none_when_flag_absent() { + let matches = build_matches("api-token", None); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(resolved(&s), None); + } + + #[test] + fn finalize_recurses_into_chain_with_cli_fallback_to_env() { + // Chain: --api-token (not passed) -> env var (set) -> file (missing) + let matches = build_matches("api-token", None); + std::env::set_var("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK", "from-env"); + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK"), + ]) + .finalize(&matches); + assert_eq!(resolved(&s), Some("from-env".to_string())); + std::env::remove_var("FERN_CLI_AUTH_TEST_CHAIN_FALLBACK"); + } + + #[test] + fn finalize_chain_cli_wins_over_env() { + // CLI is registered FIRST in the chain — when both are present, + // CLI's value takes precedence. + let matches = build_matches("api-token", Some("from-cli")); + std::env::set_var("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE", "from-env"); + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE"), + ]) + .finalize(&matches); + assert_eq!(resolved(&s), Some("from-cli".to_string())); + std::env::remove_var("FERN_CLI_AUTH_TEST_CHAIN_PRECEDENCE"); + } + + #[test] + fn finalize_passes_through_non_cli_variants() { + let matches = build_matches("ignored", None); + let s = AuthCredentialSource::literal("constant").finalize(&matches); + assert_eq!(resolved(&s), Some("constant".to_string())); + } + + #[test] + fn resolved_secret_does_not_leak_through_debug() { + // SecretString redacts its inner value in Debug — defense in + // depth against accidentally panic-printing or logging tokens. + let s = AuthCredentialSource::literal("super-secret-token"); + let secret = s.resolve().unwrap(); + let dbg = format!("{secret:?}"); + assert!(!dbg.contains("super-secret-token")); + } + + #[test] + fn debug_redacts_literal_value() { + let s = AuthCredentialSource::literal("super-secret"); + let dbg = format!("{s:?}"); + assert!(!dbg.contains("super-secret")); + assert!(dbg.contains("redacted")); + } + + // -------- credential_hints -------- + + #[test] + fn credential_hints_env() { + let s = AuthCredentialSource::from_env("MY_TOKEN"); + assert_eq!(s.credential_hints(), vec!["MY_TOKEN environment variable"]); + } + + #[test] + fn credential_hints_cli() { + let s = AuthCredentialSource::cli("api-token"); + assert_eq!(s.credential_hints(), vec!["--api-token flag"]); + } + + #[test] + fn credential_hints_file() { + let s = AuthCredentialSource::file("~/.config/token"); + assert_eq!( + s.credential_hints(), + vec!["~/.config/token file"], + ); + } + + #[test] + fn credential_hints_chain_collects_all() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("API_TOKEN"), + AuthCredentialSource::file("~/.token"), + ]); + assert_eq!( + s.credential_hints(), + vec![ + "--api-token flag", + "API_TOKEN environment variable", + "~/.token file", + ], + ); + } + + #[test] + fn credential_hints_missing_is_empty() { + assert!(AuthCredentialSource::Missing.credential_hints().is_empty()); + } + + #[test] + fn credential_hints_literal_is_empty() { + assert!(AuthCredentialSource::literal("x").credential_hints().is_empty()); + } + + #[test] + fn credential_hints_closure_without_hint_is_empty() { + let s = AuthCredentialSource::closure(|| Some("x".into())); + assert!(s.credential_hints().is_empty()); + } + + // -------- Keyring -------- + + #[test] + #[serial_test::serial] + fn keyring_source_resolves_via_active_store() { + use crate::auth::keyring_store::{set_active_store, KeyringStore, MockKeyringStore}; + let mock = Arc::new(MockKeyringStore::new()); + mock.set("svc", "OAuth2", "stashed-token").unwrap(); + set_active_store(mock.clone()); + + let s = AuthCredentialSource::keyring("svc", "OAuth2"); + assert_eq!(resolved(&s), Some("stashed-token".to_string())); + } + + #[test] + #[serial_test::serial] + fn keyring_source_returns_none_when_unset() { + use crate::auth::keyring_store::{set_active_store, MockKeyringStore}; + set_active_store(Arc::new(MockKeyringStore::new())); + + let s = AuthCredentialSource::keyring("svc", "nothing-here"); + assert_eq!(resolved(&s), None); + } + + #[test] + #[serial_test::serial] + fn keyring_source_empty_value_resolves_to_none() { + use crate::auth::keyring_store::{set_active_store, KeyringStore, MockKeyringStore}; + let mock = Arc::new(MockKeyringStore::new()); + mock.set("svc", "k", "").unwrap(); + set_active_store(mock); + + let s = AuthCredentialSource::keyring("svc", "k"); + assert_eq!(resolved(&s), None); + } + + #[test] + #[serial_test::serial] + fn keyring_source_in_chain_falls_through_when_missing() { + use crate::auth::keyring_store::{set_active_store, MockKeyringStore}; + set_active_store(Arc::new(MockKeyringStore::new())); + + let s = AuthCredentialSource::any([ + AuthCredentialSource::keyring("svc", "nothing"), + AuthCredentialSource::literal("fallback"), + ]); + assert_eq!(resolved(&s), Some("fallback".to_string())); + } + + #[test] + fn keyring_credential_hint_describes_entry() { + let s = AuthCredentialSource::keyring("elevenlabs", "OAuth2"); + let hints = s.credential_hints(); + assert_eq!(hints.len(), 1); + assert!(hints[0].contains("elevenlabs")); + assert!(hints[0].contains("OAuth2")); + assert!(hints[0].contains("auth login")); + } + + #[test] + fn keyring_cli_args_is_empty() { + let s = AuthCredentialSource::keyring("svc", "acct"); + assert!(s.cli_args().is_empty()); + } + + #[test] + fn keyring_finalize_is_pass_through() { + let cmd = clap::Command::new("test"); + let matches = Arc::new(cmd.try_get_matches_from(vec!["test"]).unwrap()); + let s = AuthCredentialSource::keyring("svc", "acct").finalize(&matches); + assert!(matches!(s, AuthCredentialSource::Keyring { .. })); + } + + #[test] + fn keyring_debug_shows_service_and_account() { + let s = AuthCredentialSource::keyring("elevenlabs", "OAuth2"); + let dbg = format!("{s:?}"); + assert!(dbg.contains("elevenlabs")); + assert!(dbg.contains("OAuth2")); + } + + #[test] + fn credential_hints_closure_with_hint_from_finalize() { + let cmd = clap::Command::new("test").arg( + clap::Arg::new("api-token").long("api-token").num_args(1), + ); + let matches = Arc::new(cmd.try_get_matches_from(vec!["test"]).unwrap()); + let s = AuthCredentialSource::cli("api-token").finalize(&matches); + assert_eq!(s.credential_hints(), vec!["--api-token flag"]); + } +} diff --git a/src/auth/error.rs b/src/auth/error.rs new file mode 100644 index 0000000..19c7554 --- /dev/null +++ b/src/auth/error.rs @@ -0,0 +1,490 @@ +//! Auth-aware HTTP error mapping. +//! +//! On a 401/403 response, we want to surface a friendly "no credentials" +//! message when the request actually went out without working auth (the +//! user just needs to set their env var / file / flag), but pass the raw +//! server error through when the request *did* carry credentials (the +//! server is rejecting them — a real backend problem). +//! +//! Per-endpoint awareness comes from +//! [`AuthProvider::has_credentials_for`][hcf]: a routing wrapper can have +//! credentials for *some* schemes but not the one this specific endpoint +//! demanded, and the friendly path should still fire. +//! +//! [hcf]: crate::auth::AuthProvider::has_credentials_for + +use serde_json::Value; + +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +/// Map an HTTP error response to a [`CliError`], honoring whether the +/// provider could have authenticated *this specific endpoint*. +/// +/// When `status` is 401/403 and the provider reports it couldn't satisfy +/// the endpoint's auth requirements, returns a friendly +/// [`CliError::Auth`] hinting the user to check their configured auth +/// source. Otherwise, parses the response body as a structured +/// `{ "error": { code, message, errors[].reason | reason } }` envelope +/// and returns [`CliError::Api`]; falls back to wrapping the raw body if +/// the response isn't JSON. +pub fn handle_error_response( + status: reqwest::StatusCode, + error_body: &str, + provider: &dyn AuthProvider, + endpoint: &EndpointAuthMetadata, +) -> Result { + if status.as_u16() == 401 || status.as_u16() == 403 { + if !provider.has_credentials_for(endpoint) { + let hints = provider.credential_hints(); + let message = if hints.is_empty() { + "Access denied. Authentication credentials are missing. \ + Check that the configured auth source for this CLI \ + (environment variable, --flag, or credential file) has a value set." + .to_string() + } else { + let joined = dedup_preserve_order(hints).join(", "); + format!( + "Access denied. Authentication credentials are missing. \ + Set {joined}.", + ) + }; + return Err(CliError::Auth(message)); + } + // Credentials were sent but the server rejected them. + // Surface which source supplied the credential so the user can + // diagnose shadowing (e.g. stale env var winning over a fresh + // `auth login`). ADR-0008 § 8e. + let hints = dedup_preserve_order(provider.credential_hints()); + if !hints.is_empty() { + let base = parse_api_error(status, error_body); + return Err(decorate_with_source_hint(base, &hints)); + } + } + Err(parse_api_error(status, error_body)) +} + +/// Append a "Credentials were supplied via: …" line to an existing +/// `CliError::Api` message, preserving the structured fields. For +/// non-`Api` variants (defensive — shouldn't happen here), pass through. +fn decorate_with_source_hint(err: CliError, hints: &[String]) -> CliError { + let joined = hints.join(", "); + match err { + CliError::Api { code, message, reason } => CliError::Api { + code, + message: format!( + "{message}\nCredentials were supplied via: {joined}. \ + Run `auth status` to see all visible sources and check for shadowing." + ), + reason, + }, + other => other, + } +} + +/// Deduplicate strings while preserving first-seen order. +fn dedup_preserve_order(items: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + items + .into_iter() + .filter(|s| seen.insert(s.clone())) + .collect() +} + +/// Shared parsing for the auth-aware error handler. Returns a structured +/// [`CliError::Api`] whether or not the body was JSON. +fn parse_api_error(status: reqwest::StatusCode, error_body: &str) -> CliError { + if let Ok(error_json) = serde_json::from_str::(error_body) { + if let Some(err_obj) = error_json.get("error") { + let code = err_obj + .get("code") + .and_then(|c| c.as_u64()) + .unwrap_or(status.as_u16() as u64) as u16; + let message = err_obj + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error") + .to_string(); + let reason = err_obj + .get("errors") + .and_then(|e| e.as_array()) + .and_then(|arr| arr.first()) + .and_then(|e| e.get("reason")) + .and_then(|r| r.as_str()) + .or_else(|| err_obj.get("reason").and_then(|r| r.as_str())) + .unwrap_or("unknown") + .to_string(); + return CliError::Api { + code, + message, + reason, + }; + } + } + CliError::Api { + code: status.as_u16(), + message: error_body.to_string(), + reason: "httpError".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::credential::AuthCredentialSource; + use crate::auth::schemes::BearerAuthProvider; + use serde_json::json; + + #[test] + fn friendly_when_provider_has_no_credentials_for_endpoint() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::Missing); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => assert!(msg.contains("Access denied")), + _ => panic!("Expected Auth"), + } + } + + #[test] + fn passes_through_when_credentials_present() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":401,"message":"bad","reason":"x"}}"#, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + assert!(matches!(err, CliError::Api { .. })); + } + + #[test] + fn parses_structured_error_envelope() { + let json_err = json!({ + "error": { + "code": 401, + "message": "Request had invalid authentication credentials.", + "errors": [{ "reason": "authError" }] + } + }) + .to_string(); + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + &json_err, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 401); + assert!(message.contains("invalid authentication credentials")); + assert_eq!(reason, "authError"); + } + other => panic!("Expected Api, got: {other:?}"), + } + } + + #[test] + fn handles_top_level_reason_field() { + let json_err = json!({ + "error": { "code": 403, "message": "Forbidden", "reason": "accessDenied" } + }) + .to_string(); + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::FORBIDDEN, + &json_err, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { reason, .. } => assert_eq!(reason, "accessDenied"), + _ => panic!("Expected Api"), + } + } + + #[test] + fn falls_back_to_raw_body_when_non_json() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::literal("t")); + let err = handle_error_response::<()>( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "Internal Server Error Text", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 500); + assert_eq!(message, "Internal Server Error Text"); + assert_eq!(reason, "httpError"); + } + _ => panic!("Expected Api"), + } + } + + #[test] + fn friendly_error_names_env_var_bearer() { + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::from_env("__FERN_TEST_BEARER_KEY"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!( + msg.contains("__FERN_TEST_BEARER_KEY"), + "expected env var name in message, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_env_var_header() { + use crate::auth::schemes::HeaderAuthProvider; + let p = HeaderAuthProvider::new( + "X-Auth-Token", + "X-Auth-Token", + AuthCredentialSource::from_env("__FERN_TEST_HEADER_KEY"), + false, + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!( + msg.contains("__FERN_TEST_HEADER_KEY"), + "expected env var name in message, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_env_var_basic() { + use crate::auth::schemes::BasicAuthProvider; + let p = BasicAuthProvider::username_only( + "ApiKeyAuth", + AuthCredentialSource::from_env("__FERN_TEST_BASIC_KEY"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!( + msg.contains("__FERN_TEST_BASIC_KEY"), + "expected env var name in message, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_cli_flag_in_chain() { + // Use a non-finalized source to verify pre-finalize hints. + let p = BearerAuthProvider::new( + "bearer", + AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("__FERN_TEST_CHAIN_TOKEN"), + ]), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!(msg.contains("--api-token"), "expected flag hint, got: {msg}"); + assert!(msg.contains("__FERN_TEST_CHAIN_TOKEN"), "expected env var hint, got: {msg}"); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_names_cli_flag_after_finalize() { + // Simulate the production path: finalize() converts Cli to Closure, + // but the hint must survive so the error message still names the flag. + let cmd = clap::Command::new("test").arg( + clap::Arg::new("api-token").long("api-token").num_args(1), + ); + let matches = std::sync::Arc::new( + cmd.try_get_matches_from(vec!["test"]).unwrap(), + ); + let source = AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("__FERN_TEST_FINALIZE_TOKEN"), + ]) + .finalize(&matches); + + let p = BearerAuthProvider::new("bearer", source); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!(msg.contains("--api-token"), "expected flag hint after finalize, got: {msg}"); + assert!(msg.contains("__FERN_TEST_FINALIZE_TOKEN"), "expected env var hint after finalize, got: {msg}"); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_fallback_when_no_hints() { + let p = BearerAuthProvider::new("bearer", AuthCredentialSource::Missing); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Auth(msg) => { + assert!(msg.contains("Access denied"), "expected fallback msg, got: {msg}"); + assert!( + msg.contains("environment variable, --flag, or credential file"), + "expected generic hint in fallback, got: {msg}", + ); + } + other => panic!("Expected Auth, got: {other:?}"), + } + } + + #[test] + fn friendly_error_json_envelope_contains_env_var() { + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::from_env("__FERN_TEST_JSON_KEY"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + "Unauthorized", + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + let json = err.to_json(); + let json_msg = json["error"]["message"].as_str().unwrap(); + assert!( + json_msg.contains("__FERN_TEST_JSON_KEY"), + "expected env var in JSON message, got: {json_msg}", + ); + } + + #[test] + fn unauthorized_with_credentials_discloses_source() { + // 401 fired with valid creds → server rejecting → disclose source. + std::env::set_var("__FERN_TEST_SHADOW_TOKEN", "stale-token"); + let p = BearerAuthProvider::new( + "bearer", + AuthCredentialSource::from_env("__FERN_TEST_SHADOW_TOKEN"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":401,"message":"bad token"}}"#, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { message, .. } => { + assert!(message.contains("__FERN_TEST_SHADOW_TOKEN")); + assert!(message.contains("auth status")); + } + other => panic!("expected Api with source-hint suffix, got: {other:?}"), + } + std::env::remove_var("__FERN_TEST_SHADOW_TOKEN"); + } + + #[test] + fn forbidden_with_credentials_discloses_source() { + std::env::set_var("__FERN_TEST_FORBIDDEN_TOKEN", "x"); + let p = BearerAuthProvider::new( + "bearer", + AuthCredentialSource::from_env("__FERN_TEST_FORBIDDEN_TOKEN"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::FORBIDDEN, + r#"{"error":{"code":403,"message":"forbidden"}}"#, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { message, .. } => { + assert!(message.contains("__FERN_TEST_FORBIDDEN_TOKEN")); + } + other => panic!("expected Api with source-hint suffix, got: {other:?}"), + } + std::env::remove_var("__FERN_TEST_FORBIDDEN_TOKEN"); + } + + #[test] + fn non_auth_status_codes_skip_source_disclosure() { + std::env::set_var("__FERN_TEST_NONAUTH_TOKEN", "x"); + let p = BearerAuthProvider::new( + "bearer", + AuthCredentialSource::from_env("__FERN_TEST_NONAUTH_TOKEN"), + ); + let err = handle_error_response::<()>( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + r#"{"error":{"code":500,"message":"server down"}}"#, + &p, + &EndpointAuthMetadata::unspecified(), + ) + .unwrap_err(); + match err { + CliError::Api { message, .. } => { + assert!(!message.contains("__FERN_TEST_NONAUTH_TOKEN")); + } + _ => panic!("expected Api"), + } + std::env::remove_var("__FERN_TEST_NONAUTH_TOKEN"); + } + + #[test] + fn dedup_removes_duplicates_preserving_order() { + let input = vec!["a".into(), "b".into(), "a".into(), "c".into(), "b".into()]; + let result = dedup_preserve_order(input); + assert_eq!(result, vec!["a", "b", "c"]); + } +} diff --git a/src/auth/keyring_store.rs b/src/auth/keyring_store.rs new file mode 100644 index 0000000..e488caf --- /dev/null +++ b/src/auth/keyring_store.rs @@ -0,0 +1,457 @@ +//! On-disk / OS-keyring credential storage for `auth login` flows. +//! +//! Two backends behind a single [`KeyringStore`] trait: +//! - [`OsKeyringStore`] — wraps [`keyring`] (macOS Keychain, Windows +//! Credential Manager, Linux secret-service). Compiled out on musl +//! targets, whose static binaries cannot link libdbus; those builds always +//! use the file backend. +//! - [`FileKeyringStore`] — writes to `~/.config//auth-keyring.json` +//! (0600) when the platform's keyring isn't available. Sibling-file +//! coexists with the pre-existing `TokenCache` from +//! [`crate::auth::oauth2`] (which uses `credentials.json` in the same +//! directory) — backward-compatible for binaries already on +//! `OAuth2TokenProvider::with_cache(...)`. +//! +//! [`auto_store`] tries the OS keyring first and falls back to file +//! silently — matches `gh`'s posture (ADR-0008). The active store is +//! installed process-globally by `CliApp::run` before bindings finalize; +//! [`AuthCredentialSource::Keyring`](crate::auth::AuthCredentialSource) +//! reads through it at resolve time. +//! +//! ## Entry shape +//! +//! Keyed by `(service=, account=)`. The value is an +//! opaque string the *caller* controls — for OAuth tokens the caller +//! serialises a JSON token bundle; for `--with-token` the caller stores +//! the raw token. This module is storage; it does not parse what it stores. + +use std::path::PathBuf; +use std::sync::{Arc, OnceLock, RwLock}; + +use crate::auth::oauth_common::{atomic_write, config_dir}; +use crate::error::CliError; + +/// Abstract credential store. Implementations either hit the OS keyring or +/// a fallback file on disk. +pub trait KeyringStore: Send + Sync + std::fmt::Debug { + /// Retrieve a stored value, if any. + fn get(&self, service: &str, account: &str) -> Result, CliError>; + /// Store / replace a value. + fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CliError>; + /// Remove a stored value. Idempotent — a missing entry is `Ok(())`. + fn delete(&self, service: &str, account: &str) -> Result<(), CliError>; + /// Short human-readable name of this backend for `auth status` output. + /// e.g. `"macOS Keychain"`, `"~/.config/elevenlabs/auth-keyring.json"`. + fn backend_label(&self) -> String; +} + +// --------------------------------------------------------------------------- +// OS keyring backend (keyring-rs) +// --------------------------------------------------------------------------- + +/// OS-native credential store backed by [`keyring`]. +#[cfg(not(target_env = "musl"))] +#[derive(Debug)] +pub struct OsKeyringStore; + +#[cfg(not(target_env = "musl"))] +impl OsKeyringStore { + /// Probe whether the platform's keyring is reachable by attempting a + /// no-op read on a sentinel entry. Returns `Ok(())` if the keyring + /// daemon / API is available, `Err` otherwise. + pub fn probe() -> Result<(), CliError> { + // Try to open an entry handle. On Linux without secret-service this + // fails at the entry constructor; on macOS / Windows it succeeds + // even if the credential doesn't yet exist. + let entry = keyring::Entry::new("fern-cli-sdk-probe", "probe") + .map_err(|e| CliError::Auth(format!("keyring probe failed: {e}")))?; + // `get_password` returns `NoEntry` on missing — which is fine for + // a probe — but a backend error here means the daemon is down. + match entry.get_password() { + Ok(_) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(CliError::Auth(format!("keyring probe failed: {e}"))), + } + } +} + +#[cfg(not(target_env = "musl"))] +impl KeyringStore for OsKeyringStore { + fn get(&self, service: &str, account: &str) -> Result, CliError> { + let entry = keyring::Entry::new(service, account) + .map_err(|e| CliError::Auth(format!("keyring open failed: {e}")))?; + match entry.get_password() { + Ok(v) => Ok(Some(v)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(CliError::Auth(format!("keyring get failed: {e}"))), + } + } + + fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CliError> { + let entry = keyring::Entry::new(service, account) + .map_err(|e| CliError::Auth(format!("keyring open failed: {e}")))?; + entry + .set_password(value) + .map_err(|e| CliError::Auth(format!("keyring set failed: {e}"))) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), CliError> { + let entry = keyring::Entry::new(service, account) + .map_err(|e| CliError::Auth(format!("keyring open failed: {e}")))?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(CliError::Auth(format!("keyring delete failed: {e}"))), + } + } + + fn backend_label(&self) -> String { + #[cfg(target_os = "macos")] + return "macOS Keychain".to_string(); + #[cfg(target_os = "windows")] + return "Windows Credential Manager".to_string(); + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + return "secret-service (Linux)".to_string(); + } +} + +// --------------------------------------------------------------------------- +// File backend (fallback) +// --------------------------------------------------------------------------- + +/// File-backed credential store at `~/.config//auth-keyring.json` +/// (0600 on Unix). +/// +/// The file is a JSON object keyed by `account`. Multiple services live in +/// separate directories. Atomic writes via temp-file-then-rename. +#[derive(Debug, Clone)] +pub struct FileKeyringStore { + /// Root config directory — usually `~/.config` (Linux), `~/Library/Application Support` (macOS), + /// `%APPDATA%` (Windows). Per-service subdir is created on demand. + root: PathBuf, +} + +impl FileKeyringStore { + /// Build a store rooted at the platform's user config directory. + /// Returns `None` if no home directory could be determined. + pub fn user_default() -> Option { + config_dir().map(|root| Self { root }) + } + + /// Build a store rooted at an arbitrary path (for testing). + pub fn at_root(root: PathBuf) -> Self { + Self { root } + } + + fn path_for(&self, service: &str) -> PathBuf { + // Distinct filename from the pre-existing `TokenCache` + // (`oauth2.rs::TokenCache::for_cli`) which uses the same + // `//credentials.json` path. The two cohabit a + // directory but write to separate files — preserves backward + // compatibility for any binary already using `OAuth2TokenProvider` + // with `.with_cache()` (e.g. `xero`). + self.root.join(service).join("auth-keyring.json") + } + + fn read_map(&self, service: &str) -> std::collections::HashMap { + let data = match std::fs::read_to_string(self.path_for(service)) { + Ok(d) => d, + Err(_) => return std::collections::HashMap::new(), + }; + serde_json::from_str(&data).unwrap_or_default() + } + + fn write_map( + &self, + service: &str, + map: &std::collections::HashMap, + ) -> Result<(), CliError> { + let path = self.path_for(service); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Auth(format!( + "Failed to create credential dir {}: {e}", + parent.display() + )) + })?; + } + let json = serde_json::to_string_pretty(map) + .map_err(|e| CliError::Auth(format!("Failed to serialize credentials: {e}")))?; + atomic_write(&path, json.as_bytes()) + } +} + +impl KeyringStore for FileKeyringStore { + fn get(&self, service: &str, account: &str) -> Result, CliError> { + Ok(self.read_map(service).get(account).cloned()) + } + + fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CliError> { + let mut map = self.read_map(service); + map.insert(account.to_string(), value.to_string()); + self.write_map(service, &map) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), CliError> { + let mut map = self.read_map(service); + if map.remove(account).is_some() { + self.write_map(service, &map)?; + } + Ok(()) + } + + fn backend_label(&self) -> String { + format!("file ({})", self.root.display()) + } +} + +// `atomic_write`, `home_dir`, and `config_dir` are shared with the +// existing `TokenCache` (oauth2.rs); see [`crate::auth::oauth_common`]. + +// --------------------------------------------------------------------------- +// Auto-pick + process-global handle +// --------------------------------------------------------------------------- + +/// Try the OS keyring; fall back to file on probe failure. Returns the +/// file backend as a last resort if no home directory is available +/// (Docker FROM scratch, etc.) — pointed at `/tmp/-credentials`, +/// which won't persist but won't crash. The user will see this in +/// `auth status` and can take action. +pub fn auto_store() -> Arc { + // Explicit override: `FERN_CLI_CREDENTIAL_STORE=file` forces the file backend, bypassing the + // OS keyring entirely. Useful for CI, containers, and hermetic tests (e.g. the generated wire + // tests) where the OS keyring is unavailable or would pop an interactive unlock prompt. The + // file location still honors `HOME` / `XDG_CONFIG_HOME`, so a test can redirect it to a temp dir. + if std::env::var_os("FERN_CLI_CREDENTIAL_STORE").is_some_and(|value| value == "file") { + tracing::debug!("FERN_CLI_CREDENTIAL_STORE=file; using file backend for credential storage"); + return match FileKeyringStore::user_default() { + Some(store) => Arc::new(store), + None => Arc::new(FileKeyringStore::at_root(PathBuf::from("/tmp/fern-cli-credentials"))), + }; + } + #[cfg(not(target_env = "musl"))] + { + if OsKeyringStore::probe().is_ok() { + tracing::debug!("Using OS keyring backend for credential storage"); + return Arc::new(OsKeyringStore); + } + } + tracing::debug!("OS keyring unavailable; falling back to file backend"); + match FileKeyringStore::user_default() { + Some(store) => Arc::new(store), + None => { + tracing::warn!("No config dir available; using /tmp for credential storage"); + Arc::new(FileKeyringStore::at_root(PathBuf::from("/tmp/fern-cli-credentials"))) + } + } +} + +/// Process-global active keyring store. Initialised once by `CliApp::run` +/// (or by tests via [`set_active_store`]). +static ACTIVE_STORE: OnceLock>> = OnceLock::new(); + +/// Install the active credential store. Idempotent: first call wins for +/// the `OnceLock` slot; subsequent calls swap the inner `Arc` via the +/// `RwLock`. Tests use the swap path to install mocks. +pub fn set_active_store(store: Arc) { + match ACTIVE_STORE.get() { + Some(slot) => { + *slot.write().expect("ACTIVE_STORE poisoned") = store; + } + None => { + let _ = ACTIVE_STORE.set(RwLock::new(store)); + } + } +} + +/// Get a handle to the active credential store, initialising it with +/// [`auto_store`] on first access if `CliApp` hasn't installed one yet. +pub fn active_store() -> Arc { + let slot = ACTIVE_STORE.get_or_init(|| RwLock::new(auto_store())); + slot.read().expect("ACTIVE_STORE poisoned").clone() +} + +// --------------------------------------------------------------------------- +// In-memory mock (for tests) +// --------------------------------------------------------------------------- + +/// In-memory store for tests. Thread-safe. +#[derive(Debug, Clone, Default)] +pub struct MockKeyringStore { + inner: Arc>>, +} + +impl MockKeyringStore { + pub fn new() -> Self { + Self::default() + } + + pub fn snapshot(&self) -> Vec<((String, String), String)> { + self.inner + .read() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + } +} + +impl KeyringStore for MockKeyringStore { + fn get(&self, service: &str, account: &str) -> Result, CliError> { + Ok(self + .inner + .read() + .unwrap() + .get(&(service.to_string(), account.to_string())) + .cloned()) + } + + fn set(&self, service: &str, account: &str, value: &str) -> Result<(), CliError> { + self.inner + .write() + .unwrap() + .insert((service.to_string(), account.to_string()), value.to_string()); + Ok(()) + } + + fn delete(&self, service: &str, account: &str) -> Result<(), CliError> { + self.inner + .write() + .unwrap() + .remove(&(service.to_string(), account.to_string())); + Ok(()) + } + + fn backend_label(&self) -> String { + "mock (in-memory)".to_string() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn file_store_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyringStore::at_root(dir.path().to_path_buf()); + + assert_eq!(store.get("elevenlabs", "OAuth2").unwrap(), None); + + store.set("elevenlabs", "OAuth2", "token-abc").unwrap(); + assert_eq!( + store.get("elevenlabs", "OAuth2").unwrap().as_deref(), + Some("token-abc") + ); + + store.delete("elevenlabs", "OAuth2").unwrap(); + assert_eq!(store.get("elevenlabs", "OAuth2").unwrap(), None); + } + + #[test] + fn file_store_multiple_accounts_per_service() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyringStore::at_root(dir.path().to_path_buf()); + + store.set("svc", "acct1", "v1").unwrap(); + store.set("svc", "acct2", "v2").unwrap(); + + assert_eq!(store.get("svc", "acct1").unwrap().as_deref(), Some("v1")); + assert_eq!(store.get("svc", "acct2").unwrap().as_deref(), Some("v2")); + + store.delete("svc", "acct1").unwrap(); + assert_eq!(store.get("svc", "acct1").unwrap(), None); + // acct2 untouched + assert_eq!(store.get("svc", "acct2").unwrap().as_deref(), Some("v2")); + } + + #[test] + fn file_store_isolates_services() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyringStore::at_root(dir.path().to_path_buf()); + + store.set("svc-a", "key", "value-a").unwrap(); + store.set("svc-b", "key", "value-b").unwrap(); + + assert_eq!(store.get("svc-a", "key").unwrap().as_deref(), Some("value-a")); + assert_eq!(store.get("svc-b", "key").unwrap().as_deref(), Some("value-b")); + } + + #[test] + fn file_store_delete_missing_is_ok() { + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyringStore::at_root(dir.path().to_path_buf()); + // Deleting a missing entry is idempotent — no error. + store.delete("nothing", "here").unwrap(); + } + + #[cfg(unix)] + #[test] + fn file_store_writes_owner_only_perms() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let store = FileKeyringStore::at_root(dir.path().to_path_buf()); + store.set("svc", "k", "v").unwrap(); + + let path = dir.path().join("svc").join("auth-keyring.json"); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "credential file should be 0600"); + } + + #[test] + fn mock_store_roundtrip() { + let store = MockKeyringStore::new(); + assert_eq!(store.get("s", "a").unwrap(), None); + store.set("s", "a", "v").unwrap(); + assert_eq!(store.get("s", "a").unwrap().as_deref(), Some("v")); + store.delete("s", "a").unwrap(); + assert_eq!(store.get("s", "a").unwrap(), None); + } + + #[test] + fn mock_store_snapshot_lists_entries() { + let store = MockKeyringStore::new(); + store.set("s", "a", "v1").unwrap(); + store.set("s", "b", "v2").unwrap(); + let mut snap = store.snapshot(); + snap.sort(); + assert_eq!( + snap, + vec![ + (("s".to_string(), "a".to_string()), "v1".to_string()), + (("s".to_string(), "b".to_string()), "v2".to_string()), + ] + ); + } + + #[test] + #[serial] + fn active_store_install_and_swap() { + let mock1 = Arc::new(MockKeyringStore::new()); + set_active_store(mock1.clone()); + // First call returns mock1. + active_store().set("svc", "acct", "v1").unwrap(); + assert_eq!(mock1.get("svc", "acct").unwrap().as_deref(), Some("v1")); + + // Swap to mock2. + let mock2 = Arc::new(MockKeyringStore::new()); + set_active_store(mock2.clone()); + active_store().set("svc", "acct", "v2").unwrap(); + assert_eq!(mock2.get("svc", "acct").unwrap().as_deref(), Some("v2")); + // mock1 retains its original value (we wrote v1 there). + assert_eq!(mock1.get("svc", "acct").unwrap().as_deref(), Some("v1")); + } + + #[test] + fn backend_labels_describe_themselves() { + let file = FileKeyringStore::at_root(PathBuf::from("/tmp/xx")); + assert!(file.backend_label().contains("file")); + let mock = MockKeyringStore::new(); + assert_eq!(mock.backend_label(), "mock (in-memory)"); + } +} diff --git a/src/auth/login.rs b/src/auth/login.rs new file mode 100644 index 0000000..a114d0b --- /dev/null +++ b/src/auth/login.rs @@ -0,0 +1,1078 @@ +//! Login flows and the `auth` subcommand surface (`login` / `logout` / `status`). +//! +//! Three flow types live here as concrete builders implementing the +//! [`LoginFlow`] trait: +//! +//! - [`TokenPasteLoginFlow`] — read a token from stdin into the keyring. +//! Always available on every CLI via `auth login --with-token`, +//! regardless of whether an OAuth flow is declared (ADR-0007). +//! - `DeviceCodeLoginFlow` — RFC 8628 device-code grant. **TB3, in +//! [`crate::auth::oauth2`].** +//! - `PkceLoginFlow` — authorization-code + PKCE with a loopback +//! listener. **TB4, in [`crate::auth::oauth2`].** +//! +//! The `auth` subcommand is always grafted into every CliApp at run +//! time (ADR-0007 § "always-graft"). It exposes: +//! +//! ```text +//! auth login # run the declared flow +//! auth login --with-token # paste a token (universal escape hatch) +//! auth logout # clear keyring entry +//! auth status # show every credential source per scheme, +//! # marking shadowing +//! ``` + +use std::io::{IsTerminal, Write}; +use std::sync::Arc; + +use clap::{Arg, ArgAction, ArgMatches, Command}; + +use crate::auth::builder::SchemeBinding; +use crate::auth::credential::AuthCredentialSource; +use crate::auth::keyring_store::active_store; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Color helpers — used by `auth status` and the login-flow progress lines. +// --------------------------------------------------------------------------- +// +// Rules: +// - Color only when stderr is a TTY (status writes there; piped output +// stays plain). +// - Honor the `NO_COLOR` convention (https://no-color.org). +// - `--json` output never gets colored (separate code path). +// +// Codes: +// bold=1, dim=2, red=31, green=32, yellow=33, bright-black/grey=90, reset=0 +fn use_colors() -> bool { + if std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty()) { + return false; + } + std::io::stderr().is_terminal() +} + +pub(crate) fn paint(s: &str, code: &str) -> String { + if use_colors() { + format!("\x1b[{code}m{s}\x1b[0m") + } else { + s.to_string() + } +} + +/// Bold for scheme headers. +pub(crate) fn bold(s: &str) -> String { + paint(s, "1") +} + +/// Green for "active source" + flow-success checkmarks. +pub(crate) fn green(s: &str) -> String { + paint(s, "32") +} + +/// Dim (grey) for shadowed and missing sources — keeps them visible but +/// pushes them visually behind the active source. +pub(crate) fn dim(s: &str) -> String { + paint(s, "2") +} + +/// Yellow for warnings ("env var shadows keyring", "session expired"). +pub(crate) fn yellow(s: &str) -> String { + paint(s, "33") +} + +// --------------------------------------------------------------------------- +// LoginFlow trait +// --------------------------------------------------------------------------- + +/// Per-binding declaration of how ` auth login` acquires credentials. +/// +/// Each CLI declares **exactly one** flow per scheme (ADR-0007 § one-shot). +/// `--with-token` is a runtime escape hatch on every flow, not a separate +/// declaration. +pub trait LoginFlow: Send + Sync + std::fmt::Debug { + /// Diagnostic flow-type name ("device-code", "pkce", "token-paste"). + fn flow_type(&self) -> &'static str; + /// Auth scheme name this flow populates — matches the scheme key in + /// the spec's `components.securitySchemes`. + fn scheme_name(&self) -> &str; + /// Execute the flow. On success, the resulting credential lives in + /// the active keyring at `(cli_name, scheme_name)`. Implementations + /// should print human-readable progress to stderr. + fn run(&self, ctx: &LoginContext) -> Result<(), CliError>; + /// Optional hint URL surfaced to the user during `--with-token` + /// (where to grab a token from). All flows can carry this — for + /// PKCE / device-code it's the dashboard URL the user would use if + /// they bypassed the OAuth dance. + fn token_paste_url(&self) -> Option<&str> { + None + } + /// Optional request-time auth provider the flow needs registered. + /// + /// `TokenPasteLoginFlow` returns `None` — the paste path stores a + /// raw string in the keyring and the existing typed builders' + /// keyring source picks it up via the standard chain. + /// + /// `DeviceCodeLoginFlow` / `PkceLoginFlow` return + /// `Some(OAuth2KeyringProvider)` — refresh + Bearer-header logic + /// can't live in the credential-source layer because it requires + /// async I/O and access to the flow's `token_url` and `client_id`. + fn build_auth_provider( + &self, + _cli_name: &str, + ) -> Option { + None + } +} + +/// Runtime context for executing a login flow. +#[derive(Debug, Clone)] +pub struct LoginContext { + pub cli_name: String, + /// `--no-browser` flag — relevant for PKCE / device-code. + pub no_browser: bool, +} + +/// Boxed login-flow handle stored on `CliApp`. +pub type DynLoginFlow = Arc; + +// --------------------------------------------------------------------------- +// TokenPasteLoginFlow +// --------------------------------------------------------------------------- + +/// Read a token from stdin and stash it in the keyring. The universal +/// "paste a token" path — works for OAuth bearer tokens, raw API keys, +/// anything that's a single opaque string. +/// +/// This is the *declared* flow when a binary has no OAuth. It's also +/// the *escape hatch* (`auth login --with-token`) on every other binary, +/// regardless of declared flow. +#[derive(Debug, Clone)] +pub struct TokenPasteLoginFlow { + scheme: String, + /// Optional hint URL — where the user can find a token to paste. + /// Surfaces in the prompt printed before stdin is read. + /// Derived from `x-fern-cli-auth.token_paste_url` upstream, or set + /// directly via [`Self::token_paste_url`]. + token_paste_url: Option, +} + +impl TokenPasteLoginFlow { + pub fn new(scheme: impl Into) -> Self { + Self { + scheme: scheme.into(), + token_paste_url: None, + } + } + + pub fn token_paste_url(mut self, url: impl Into) -> Self { + self.token_paste_url = Some(url.into()); + self + } +} + +impl LoginFlow for TokenPasteLoginFlow { + fn flow_type(&self) -> &'static str { + "token-paste" + } + fn scheme_name(&self) -> &str { + &self.scheme + } + fn run(&self, ctx: &LoginContext) -> Result<(), CliError> { + run_token_paste(&ctx.cli_name, &self.scheme, self.token_paste_url.as_deref()) + } + fn token_paste_url(&self) -> Option<&str> { + self.token_paste_url.as_deref() + } +} + +/// Concrete token-paste implementation, separated so the universal +/// `--with-token` escape hatch (TB2) can reuse it without an +/// explicit `TokenPasteLoginFlow` declaration on the binding. +pub fn run_token_paste( + cli_name: &str, + scheme_name: &str, + token_paste_url: Option<&str>, +) -> Result<(), CliError> { + let stderr = std::io::stderr(); + let mut err = stderr.lock(); + + if let Some(url) = token_paste_url { + let _ = writeln!(err, "Get your token at: {url}"); + } + let _ = writeln!(err, "Paste your token (input will be read from stdin):"); + let _ = err.flush(); + + let token = read_token_from_stdin()?; + active_store().set(cli_name, scheme_name, &token)?; + + let _ = writeln!( + err, + "{}", + green(&format!( + "✓ Stored credential for {cli_name}:{scheme_name} in {}", + active_store().backend_label() + )) + ); + + warn_if_env_shadows(&mut err, cli_name, scheme_name); + Ok(()) +} + +/// Read a single line from stdin, trimmed. Returns `Auth("No token …")` +/// if empty / EOF. +fn read_token_from_stdin() -> Result { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin + .lock() + .read_line(&mut line) + .map_err(|e| CliError::Auth(format!("Failed to read token from stdin: {e}")))?; + let trimmed = line.trim().to_string(); + if trimmed.is_empty() { + return Err(CliError::Auth( + "No token provided on stdin. Pipe the token in or type it followed by Enter." + .to_string(), + )); + } + Ok(trimmed) +} + +// --------------------------------------------------------------------------- +// `auth` subcommand assembly + dispatch +// --------------------------------------------------------------------------- + +/// Build the `auth` subcommand subtree. Grafted onto every CliApp by +/// [`crate::app::CliApp::run`] regardless of declared flows. +pub fn build_auth_command() -> Command { + Command::new("auth") + .about("Manage credentials (login / logout / status)") + .arg_required_else_help(true) + .subcommand( + Command::new("login") + .about("Authenticate this CLI (runs the declared OAuth flow, or pastes a token)") + .arg( + Arg::new("with-token") + .long("with-token") + .action(ArgAction::SetTrue) + .help("Bypass any declared OAuth flow; read a token from stdin into the keyring"), + ) + .arg( + Arg::new("scheme") + .long("scheme") + .help("Auth scheme name (required when multiple are declared)"), + ) + .arg( + Arg::new("no-browser") + .long("no-browser") + .action(ArgAction::SetTrue) + .help("Don't auto-open a browser (PKCE / device-code flows)"), + ), + ) + .subcommand( + Command::new("logout") + .about("Remove stored credentials from the keyring") + .arg( + Arg::new("scheme") + .long("scheme") + .help("Auth scheme name (required when multiple are declared)"), + ), + ) + .subcommand( + Command::new("status") + .about("Show every credential source for each declared scheme") + .arg( + Arg::new("json") + .long("json") + .action(ArgAction::SetTrue) + .help("Emit machine-readable JSON (for agents)"), + ), + ) +} + +/// Dispatch into the matched `auth` subcommand. Called by `CliApp::run` +/// after argv is parsed. +/// +/// `out` is the stdout sink used for machine-readable output (`status --json`). +/// Human-readable progress goes to stderr unconditionally. +/// +/// Returns `Ok(())` on success and exits zero; `Err(CliError)` surfaces +/// the standard JSON error and exits non-zero. +pub fn dispatch_auth( + matches: &ArgMatches, + cli_name: &str, + auth_bindings: &[(String, SchemeBinding)], + login_flows: &[DynLoginFlow], + out: &mut W, +) -> Result<(), CliError> { + match matches.subcommand() { + Some(("login", m)) => handle_login(m, cli_name, auth_bindings, login_flows), + Some(("logout", m)) => handle_logout(m, cli_name, auth_bindings), + Some(("status", m)) => handle_status(m, cli_name, auth_bindings, login_flows, out), + _ => Err(CliError::Validation( + "auth requires a subcommand: login, logout, or status".to_string(), + )), + } +} + +fn handle_login( + matches: &ArgMatches, + cli_name: &str, + auth_bindings: &[(String, SchemeBinding)], + login_flows: &[DynLoginFlow], +) -> Result<(), CliError> { + let with_token = matches.get_flag("with-token"); + let no_browser = matches.get_flag("no-browser"); + + // `--with-token` is the universal escape hatch (ADR-0007 § + // "always-graft"): every Fern CLI accepts a pasted token, regardless + // of whether `auth(...)` / `login_flow(...)` registered any schemes. + // When nothing is registered, the user must name the keyring slot + // explicitly via `--scheme`; otherwise resolve_scheme's normal rules + // (single-binding / single-flow / disambiguation) apply. + let scheme = if with_token && auth_bindings.is_empty() && login_flows.is_empty() { + matches + .get_one::("scheme") + .cloned() + .ok_or_else(|| { + CliError::Validation( + "This CLI declares no auth schemes. Pass `--scheme ` to choose \ + the keyring slot for your token.".to_string(), + ) + })? + } else { + resolve_scheme(matches.get_one::("scheme"), auth_bindings, login_flows)? + }; + + if with_token { + // Universal paste path — surfaces the token_paste_url hint from + // any declared flow for this scheme (regardless of flow type), + // since the dashboard URL is meaningful for paste no matter which + // OAuth grant the binary declares. + let hint: Option = login_flows + .iter() + .find(|f| f.scheme_name() == scheme) + .and_then(|f| f.token_paste_url().map(str::to_string)); + return run_token_paste(cli_name, &scheme, hint.as_deref()); + } + + // Run the declared flow for this scheme. + let flow = login_flows.iter().find(|f| f.scheme_name() == scheme); + match flow { + Some(f) => { + let ctx = LoginContext { + cli_name: cli_name.to_string(), + no_browser, + }; + f.run(&ctx)?; + warn_if_env_shadows(&mut std::io::stderr().lock(), cli_name, &scheme); + Ok(()) + } + None => Err(CliError::Validation(format!( + "No login flow declared for scheme `{scheme}`. Use `auth login --with-token` to paste a token directly." + ))), + } +} + +fn handle_logout( + matches: &ArgMatches, + cli_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) -> Result<(), CliError> { + let scheme = resolve_scheme(matches.get_one::("scheme"), auth_bindings, &[])?; + active_store().delete(cli_name, &scheme)?; + let _ = writeln!( + std::io::stderr().lock(), + "{}", + green(&format!( + "✓ Removed credential for {cli_name}:{scheme} from {}.", + active_store().backend_label() + )) + ); + Ok(()) +} + +fn handle_status( + matches: &ArgMatches, + cli_name: &str, + auth_bindings: &[(String, SchemeBinding)], + login_flows: &[DynLoginFlow], + out: &mut W, +) -> Result<(), CliError> { + let as_json = matches.get_flag("json"); + let store = active_store(); + let backend = store.backend_label(); + + if as_json { + let entries: Vec<_> = auth_bindings + .iter() + .map(|(name, binding)| status_entry_for(cli_name, name, binding, login_flows)) + .collect(); + let payload = serde_json::json!({ + "cli": cli_name, + "backend": backend, + "schemes": entries, + }); + writeln!(out, "{}", serde_json::to_string_pretty(&payload).unwrap()) + .map_err(|e| CliError::Other(e.into()))?; + return Ok(()); + } + + let mut stderr = std::io::stderr().lock(); + let _ = writeln!( + stderr, + "{}: credential status (storage backend: {backend})", + bold(cli_name) + ); + let _ = writeln!(stderr); + + if auth_bindings.is_empty() { + let _ = writeln!(stderr, " No auth schemes are declared on this CLI."); + return Ok(()); + } + + for (scheme_name, binding) in auth_bindings { + let flow = login_flows + .iter() + .find(|f| f.scheme_name() == scheme_name); + + let _ = writeln!( + stderr, + " {} {}{}", + bold("Scheme:"), + bold(scheme_name), + flow.map(|f| dim(&format!(" (login flow: {})", f.flow_type()))) + .unwrap_or_default() + ); + + let sources = expand_sources(scheme_name, binding, login_flows, cli_name); + if sources.is_empty() { + let _ = writeln!(stderr, " {}", dim("(no credential sources bound)")); + let _ = writeln!(stderr); + continue; + } + + // Mark the first source that resolves as ACTIVE (green); subsequent + // resolving sources are SHADOWED (dim) — the credential is there + // but a higher-precedence source is winning. Non-resolving sources + // are MISSING (also dim) — they're declared but unset. + let mut active_found = false; + for src in &sources { + let has_value = src.resolve().is_some(); + let desc = describe_source(src); + let line = match (has_value, active_found) { + (true, false) => { + active_found = true; + green(&format!("✓ active {desc}")) + } + (true, true) => dim(&format!(" shadowed {desc}")), + (false, _) => dim(&format!(" missing {desc}")), + }; + let _ = writeln!(stderr, " {line}"); + } + if !active_found { + let suffix = if login_flows.iter().any(|f| f.scheme_name() == scheme_name) { + String::new() + } else { + " --with-token".to_string() + }; + let _ = writeln!( + stderr, + " {}", + yellow(&format!( + "Not logged in. Run `{cli_name} auth login{suffix}` to authenticate." + )) + ); + } + let _ = writeln!(stderr); + } + Ok(()) +} + +/// Resolve which scheme name to operate on. With one binding, infer it; +/// with multiple, require `--scheme`. Used by login + logout. +fn resolve_scheme( + explicit: Option<&String>, + auth_bindings: &[(String, SchemeBinding)], + login_flows: &[DynLoginFlow], +) -> Result { + if let Some(s) = explicit { + return Ok(s.clone()); + } + // Prefer the schemes that have a declared LoginFlow when disambiguating. + let flow_schemes: Vec<_> = login_flows.iter().map(|f| f.scheme_name()).collect(); + if flow_schemes.len() == 1 { + return Ok(flow_schemes[0].to_string()); + } + if auth_bindings.len() == 1 { + return Ok(auth_bindings[0].0.clone()); + } + if auth_bindings.is_empty() { + return Err(CliError::Validation( + "This CLI does not declare any auth schemes; nothing to log in to.".to_string(), + )); + } + let names: Vec<&str> = auth_bindings.iter().map(|(n, _)| n.as_str()).collect(); + Err(CliError::Validation(format!( + "Multiple auth schemes declared ({}). Pass --scheme to disambiguate.", + names.join(", "), + ))) +} + +/// Expand a binding's credential source(s) into a flat list of leaf +/// sources (Chain flattened), for status reporting. +/// +/// For `SchemeBinding::Custom` bindings whose scheme has a declared +/// login flow (i.e. registered via `CliApp::login_flow`), we synthesize +/// a `Keyring` source for the matching `(cli_name, scheme_name)` slot — +/// the OAuth login flows store their token bundle there, and the status +/// surface needs to see it. Without this, OAuth-logged-in users would +/// see "Not logged in" in `auth status` even though the keyring entry +/// is populated and apply() can read it on every request. +fn expand_sources( + scheme_name: &str, + binding: &SchemeBinding, + login_flows: &[DynLoginFlow], + cli_name: &str, +) -> Vec { + match binding { + SchemeBinding::Token(s) => flatten_chain(s.clone()), + SchemeBinding::Basic { username, password } => { + let mut out = flatten_chain(username.clone()); + out.extend(flatten_chain(password.clone())); + out + } + SchemeBinding::Custom(_) => { + if login_flows.iter().any(|f| f.scheme_name() == scheme_name) { + vec![AuthCredentialSource::keyring(cli_name, scheme_name)] + } else { + Vec::new() + } + } + } +} + +fn flatten_chain(s: AuthCredentialSource) -> Vec { + match s { + AuthCredentialSource::Chain(children) => children + .into_iter() + .flat_map(flatten_chain) + .collect(), + other => vec![other], + } +} + +fn describe_source(s: &AuthCredentialSource) -> String { + match s { + AuthCredentialSource::Env(name) => format!("{name} env var"), + AuthCredentialSource::Cli(arg) => format!("--{arg} flag"), + AuthCredentialSource::File(path) => format!("{} file", path.display()), + AuthCredentialSource::Literal(_) => "built-in literal".to_string(), + AuthCredentialSource::Keyring { service, account } => { + format!("keyring entry {service}:{account}") + } + AuthCredentialSource::Closure(_, Some(hint)) => hint.clone(), + AuthCredentialSource::Closure(_, None) => "custom resolver".to_string(), + AuthCredentialSource::Chain(_) => unreachable!("flatten_chain removes nested Chains"), + AuthCredentialSource::Missing => "(unbound)".to_string(), + } +} + +/// Print a shadow warning if an env var has a value while a keyring +/// entry is about to be written (or has just been written) — saves +/// the user from the "I logged in but my old env still wins" footgun +/// (ADR-0008 § shadowing). +fn warn_if_env_shadows(out: &mut W, cli_name: &str, scheme_name: &str) { + // Heuristic env-var names to check: _, _TOKEN, + // _API_KEY, _TOKEN. Matches what generated binaries + // typically wire. + let upper_cli = cli_name.to_uppercase().replace('-', "_"); + let upper_scheme = scheme_name.to_uppercase().replace('-', "_"); + let candidates = [ + format!("{upper_cli}_{upper_scheme}"), + format!("{upper_cli}_TOKEN"), + format!("{upper_cli}_API_KEY"), + upper_scheme.clone(), + ]; + for name in candidates { + if let Ok(v) = std::env::var(&name) { + if !v.trim().is_empty() { + let _ = writeln!( + out, + "{}", + yellow(&format!( + "⚠ Warning: env var `{name}` is set; it will shadow the keyring entry. \ + Unset it to use the credential you just stored." + )) + ); + return; + } + } + } +} + +fn status_entry_for( + cli_name: &str, + scheme_name: &str, + binding: &SchemeBinding, + login_flows: &[DynLoginFlow], +) -> serde_json::Value { + let flow = login_flows + .iter() + .find(|f| f.scheme_name() == scheme_name) + .map(|f| f.flow_type()); + let sources = expand_sources(scheme_name, binding, login_flows, cli_name); + let mut active_found = false; + let entries: Vec = sources + .iter() + .map(|s| { + let has_value = s.resolve().is_some(); + let state = match (has_value, active_found) { + (true, false) => { + active_found = true; + "active" + } + (true, true) => "shadowed", + (false, _) => "missing", + }; + serde_json::json!({ + "state": state, + "source": describe_source(s), + }) + }) + .collect(); + serde_json::json!({ + "scheme": scheme_name, + "login_flow": flow, + "logged_in": active_found, + "sources": entries, + "cli": cli_name, + }) +} + +/// Inject a keyring source into each binding's credential chain so +/// every CLI's auth resolution gets the keyring layer for free. +/// +/// Called by `CliApp::run` before bindings are propagated. Precedence +/// stays CLI > env > keyring > file — keyring is appended last in the +/// existing chain so any explicit user-configured source wins. +pub fn inject_keyring_sources( + cli_name: &str, + bindings: &mut [(String, SchemeBinding)], +) { + for (scheme_name, binding) in bindings.iter_mut() { + let kr = AuthCredentialSource::keyring(cli_name, scheme_name.as_str()); + match binding { + SchemeBinding::Token(src) => { + let existing = std::mem::replace(src, AuthCredentialSource::Missing); + *src = append_to_chain(existing, kr); + } + // Basic auth: username/password are separate, but the typical + // shape stores both in a single keyring entry encoded as JSON. + // For v1 we leave Basic alone — username-only/password-only + // schemes already work via env; full Basic is a v2 concern. + SchemeBinding::Basic { .. } => {} + SchemeBinding::Custom(_) => {} + } + } +} + +/// Wire on-disk token caching into every [`OAuth2TokenProvider`] reachable +/// from the bindings. Called after [`inject_keyring_sources`] but before +/// propagation to subcommands. +pub fn inject_oauth2_caches(cli_name: &str, bindings: &mut [(String, SchemeBinding)]) { + for (_scheme_name, binding) in bindings.iter_mut() { + if let SchemeBinding::Custom(provider) = binding { + provider.inject_token_cache(cli_name); + } + } +} + +fn append_to_chain( + existing: AuthCredentialSource, + addition: AuthCredentialSource, +) -> AuthCredentialSource { + match existing { + AuthCredentialSource::Chain(mut sources) => { + sources.push(addition); + AuthCredentialSource::Chain(sources) + } + AuthCredentialSource::Missing => addition, + single => AuthCredentialSource::Chain(vec![single, addition]), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::keyring_store::{set_active_store, KeyringStore, MockKeyringStore}; + use serial_test::serial; + use std::sync::Arc; + + #[test] + fn token_paste_flow_type_and_scheme() { + let f = TokenPasteLoginFlow::new("OAuth2"); + assert_eq!(f.flow_type(), "token-paste"); + assert_eq!(f.scheme_name(), "OAuth2"); + } + + #[test] + fn token_paste_with_url_carries_hint() { + let f = TokenPasteLoginFlow::new("OAuth2") + .token_paste_url("https://example.com/settings"); + assert_eq!(f.token_paste_url.as_deref(), Some("https://example.com/settings")); + } + + #[test] + fn inject_keyring_into_single_env_source() { + let mut bindings = vec![( + "OAuth2".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("MY_TOKEN")), + )]; + inject_keyring_sources("my-cli", &mut bindings); + match &bindings[0].1 { + SchemeBinding::Token(AuthCredentialSource::Chain(sources)) => { + assert_eq!(sources.len(), 2); + assert!(matches!(sources[0], AuthCredentialSource::Env(_))); + assert!(matches!( + sources[1], + AuthCredentialSource::Keyring { ref service, ref account } + if service == "my-cli" && account == "OAuth2" + )); + } + _ => panic!("expected Token(Chain([Env, Keyring]))"), + } + } + + #[test] + fn inject_keyring_appends_to_existing_chain() { + let mut bindings = vec![( + "scheme1".to_string(), + SchemeBinding::Token(AuthCredentialSource::any([ + AuthCredentialSource::cli("api-token"), + AuthCredentialSource::from_env("MY_TOKEN"), + ])), + )]; + inject_keyring_sources("cli", &mut bindings); + match &bindings[0].1 { + SchemeBinding::Token(AuthCredentialSource::Chain(sources)) => { + assert_eq!(sources.len(), 3); + assert!(matches!(sources[0], AuthCredentialSource::Cli(_))); + assert!(matches!(sources[1], AuthCredentialSource::Env(_))); + assert!(matches!(sources[2], AuthCredentialSource::Keyring { .. })); + } + _ => panic!("expected Chain"), + } + } + + #[test] + fn inject_keyring_promotes_missing_to_keyring_alone() { + let mut bindings = vec![( + "scheme1".to_string(), + SchemeBinding::Token(AuthCredentialSource::Missing), + )]; + inject_keyring_sources("cli", &mut bindings); + match &bindings[0].1 { + SchemeBinding::Token(AuthCredentialSource::Keyring { service, account }) => { + assert_eq!(service, "cli"); + assert_eq!(account, "scheme1"); + } + _ => panic!("expected single Keyring source"), + } + } + + #[test] + fn resolve_scheme_single_binding_no_arg() { + let bindings = vec![( + "only".to_string(), + SchemeBinding::Token(AuthCredentialSource::Missing), + )]; + let s = resolve_scheme(None, &bindings, &[]).unwrap(); + assert_eq!(s, "only"); + } + + #[test] + fn resolve_scheme_explicit_wins() { + let bindings = vec![ + ("a".to_string(), SchemeBinding::Token(AuthCredentialSource::Missing)), + ("b".to_string(), SchemeBinding::Token(AuthCredentialSource::Missing)), + ]; + let s = resolve_scheme(Some(&"b".to_string()), &bindings, &[]).unwrap(); + assert_eq!(s, "b"); + } + + #[test] + fn resolve_scheme_multiple_bindings_no_arg_errors() { + let bindings = vec![ + ("a".to_string(), SchemeBinding::Token(AuthCredentialSource::Missing)), + ("b".to_string(), SchemeBinding::Token(AuthCredentialSource::Missing)), + ]; + let err = resolve_scheme(None, &bindings, &[]).unwrap_err(); + match err { + CliError::Validation(m) => { + assert!(m.contains("--scheme")); + assert!(m.contains("a")); + assert!(m.contains("b")); + } + _ => panic!("expected Validation error"), + } + } + + #[test] + fn resolve_scheme_disambiguates_via_single_login_flow() { + // Multiple bindings, but only one has a declared login flow → use it. + let bindings = vec![ + ("a".to_string(), SchemeBinding::Token(AuthCredentialSource::Missing)), + ("b".to_string(), SchemeBinding::Token(AuthCredentialSource::Missing)), + ]; + let flows: Vec = vec![Arc::new(TokenPasteLoginFlow::new("b"))]; + let s = resolve_scheme(None, &bindings, &flows).unwrap(); + assert_eq!(s, "b"); + } + + #[test] + #[serial] + fn paint_is_a_noop_when_colors_are_disabled() { + // `paint` must emit no ANSI codes when colors are off. Force the + // no-color path explicitly via `NO_COLOR` rather than relying on + // stderr not being a TTY: `cargo test` captures each test's stdout + // but leaves stderr attached to the terminal, so in an interactive + // shell `stderr().is_terminal()` is true and `use_colors()` would + // flip on — making this test's outcome depend on how the suite was + // launched. `#[serial]` keeps the process-global env mutation from + // racing the other env-touching tests in this module. + let prev = std::env::var_os("NO_COLOR"); + std::env::set_var("NO_COLOR", "1"); + + assert_eq!(green("ok"), "ok"); + assert!(!dim("shadow").contains('\x1b')); + assert!(!yellow("warn").contains('\x1b')); + assert!(!bold("hdr").contains('\x1b')); + + match prev { + Some(value) => std::env::set_var("NO_COLOR", value), + None => std::env::remove_var("NO_COLOR"), + } + } + + #[test] + #[serial] + fn with_token_resolves_scheme_from_explicit_arg_when_bindingless() { + // ADR-0007 § always-graft: ElevenLabs (no auth_bindings, no + // login_flows) must still accept `auth login --with-token`. With + // --scheme passed, resolution picks it up from the flag instead + // of bailing on the empty bindings list. (The full handle_login + // path that reads stdin is exercised by tests/oauth_fixture_wire.rs.) + let cmd = build_auth_command(); + let m = cmd + .try_get_matches_from(vec![ + "auth", "login", "--with-token", "--scheme", "api-key", + ]) + .unwrap(); + let (_, sub) = m.subcommand().unwrap(); + assert!(sub.get_flag("with-token")); + assert_eq!(sub.get_one::("scheme").map(String::as_str), Some("api-key")); + } + + #[test] + #[serial] + fn with_token_errors_on_bindingless_cli_without_explicit_scheme() { + // Same setup but no --scheme. handle_login should produce a + // Validation error pointing the user at --scheme — instead of + // the old "no auth schemes; nothing to log in to" error that + // violated ADR-0007's "works on every CLI" promise. + use crate::auth::keyring_store::{set_active_store, MockKeyringStore}; + set_active_store(Arc::new(MockKeyringStore::new())); + + let cmd = build_auth_command(); + let m = cmd + .try_get_matches_from(vec!["auth", "login", "--with-token"]) + .unwrap(); + let (_, sub) = m.subcommand().unwrap(); + + let bindings: Vec<(String, SchemeBinding)> = vec![]; + let flows: Vec = vec![]; + match handle_login(sub, "my-cli", &bindings, &flows) { + Err(CliError::Validation(msg)) => { + assert!( + msg.contains("declares no auth schemes") && msg.contains("--scheme"), + "expected 'declares no auth schemes ... --scheme' message, got: {msg}" + ); + } + other => panic!("expected Validation error, got: {other:?}"), + } + } + + #[test] + fn expand_sources_synthesises_keyring_for_oauth_custom_binding() { + use crate::auth::provider::NoAuthProvider; + // OAuth flows register their auth provider as Custom; expand_sources + // must still surface the keyring slot for `auth status`. + let binding = SchemeBinding::Custom(std::sync::Arc::new(NoAuthProvider)); + // Use a TokenPasteLoginFlow as a stand-in for any LoginFlow declared + // against scheme "OAuth2" — the only thing expand_sources reads is + // scheme_name(). + let flow: DynLoginFlow = std::sync::Arc::new(TokenPasteLoginFlow::new("OAuth2")); + let sources = expand_sources("OAuth2", &binding, &[flow], "my-cli"); + assert_eq!(sources.len(), 1); + match &sources[0] { + AuthCredentialSource::Keyring { service, account } => { + assert_eq!(service, "my-cli"); + assert_eq!(account, "OAuth2"); + } + other => panic!("expected synthesised Keyring source, got {other:?}"), + } + } + + #[test] + fn expand_sources_returns_empty_for_custom_with_no_login_flow() { + use crate::auth::provider::NoAuthProvider; + // Custom bindings registered manually (no matching login_flow) stay + // opaque — status output shows "(no credential sources bound)". + let binding = SchemeBinding::Custom(std::sync::Arc::new(NoAuthProvider)); + let sources = expand_sources("OAuth2", &binding, &[], "my-cli"); + assert!(sources.is_empty()); + } + + #[test] + fn flatten_chain_handles_nested_chains() { + let s = AuthCredentialSource::any([ + AuthCredentialSource::from_env("A"), + AuthCredentialSource::any([ + AuthCredentialSource::from_env("B"), + AuthCredentialSource::literal("c"), + ]), + ]); + let flat = flatten_chain(s); + assert_eq!(flat.len(), 3); + } + + #[test] + fn describe_source_covers_all_variants() { + assert_eq!( + describe_source(&AuthCredentialSource::from_env("FOO")), + "FOO env var" + ); + assert_eq!( + describe_source(&AuthCredentialSource::cli("api-token")), + "--api-token flag" + ); + assert!(describe_source(&AuthCredentialSource::keyring("cli", "scheme")) + .contains("cli:scheme")); + } + + #[test] + #[serial] + fn logout_clears_keyring_entry() { + let mock = Arc::new(MockKeyringStore::new()); + mock.set("my-cli", "OAuth2", "token-abc").unwrap(); + set_active_store(mock.clone()); + + let cmd = build_auth_command(); + let m = cmd + .try_get_matches_from(vec!["auth", "logout", "--scheme", "OAuth2"]) + .unwrap(); + let bindings = vec![( + "OAuth2".to_string(), + SchemeBinding::Token(AuthCredentialSource::Missing), + )]; + let (_, sub) = m.subcommand().unwrap(); + handle_logout(sub, "my-cli", &bindings).unwrap(); + let _ = handle_logout; // silence unused warning if other paths shift + + assert_eq!(mock.get("my-cli", "OAuth2").unwrap(), None); + } + + #[test] + #[serial] + fn status_marks_env_as_active_keyring_as_shadowed() { + let mock = Arc::new(MockKeyringStore::new()); + mock.set("my-cli", "OAuth2", "from-keyring").unwrap(); + set_active_store(mock); + std::env::set_var("MY_CLI_OAUTH2_TEST", "from-env"); + + let chain = AuthCredentialSource::any([ + AuthCredentialSource::from_env("MY_CLI_OAUTH2_TEST"), + AuthCredentialSource::keyring("my-cli", "OAuth2"), + ]); + let sources = expand_sources( + "OAuth2", + &SchemeBinding::Token(chain), + &[], + "my-cli", + ); + assert_eq!(sources.len(), 2); + let env_resolves = sources[0].resolve().is_some(); + let keyring_resolves = sources[1].resolve().is_some(); + assert!(env_resolves); + assert!(keyring_resolves); + // (Status output assertion happens in handle_status, but the + // underlying state — both resolve, env is first — is what makes + // shadowing detectable. Direct test of handler is harder because + // it writes to stderr.) + + std::env::remove_var("MY_CLI_OAUTH2_TEST"); + } + + #[test] + fn inject_oauth2_caches_wires_cache_into_custom_provider() { + use crate::auth::oauth2::{OAuth2Grant, OAuth2TokenProvider}; + + let provider = Arc::new(OAuth2TokenProvider::new( + "OAuth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "CID".to_string(), + client_secret_env: "CSEC".to_string(), + scope: None, + }, + )); + assert!(!provider.has_cache()); + + let mut bindings: Vec<(String, SchemeBinding)> = vec![( + "OAuth2".to_string(), + SchemeBinding::Custom(provider.clone()), + )]; + inject_oauth2_caches("test-cli", &mut bindings); + assert!(provider.has_cache()); + } + + #[test] + fn inject_oauth2_caches_is_noop_for_token_bindings() { + let mut bindings: Vec<(String, SchemeBinding)> = vec![( + "bearer".to_string(), + SchemeBinding::Token(AuthCredentialSource::from_env("MY_TOKEN")), + )]; + // Should not panic or modify Token bindings + inject_oauth2_caches("test-cli", &mut bindings); + assert!(matches!(bindings[0].1, SchemeBinding::Token(_))); + } + + #[test] + fn inject_oauth2_caches_idempotent() { + use crate::auth::oauth2::{OAuth2Grant, OAuth2TokenProvider}; + + let provider = Arc::new(OAuth2TokenProvider::new( + "OAuth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "CID".to_string(), + client_secret_env: "CSEC".to_string(), + scope: None, + }, + )); + let mut bindings: Vec<(String, SchemeBinding)> = vec![( + "OAuth2".to_string(), + SchemeBinding::Custom(provider.clone()), + )]; + inject_oauth2_caches("test-cli", &mut bindings); + assert!(provider.has_cache()); + // Calling again should be a no-op (OnceLock prevents double-set) + inject_oauth2_caches("other-cli", &mut bindings); + assert!(provider.has_cache()); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..32d407b --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,77 @@ +//! Authentication provider architecture. +//! +//! Modeled on the Fern TypeScript SDK generator's `core.AuthProvider` contract: +//! every auth scheme implements [`AuthProvider`], which mutates an outgoing +//! [`reqwest::RequestBuilder`] with the appropriate headers. Composition +//! wrappers let multiple schemes coexist: +//! +//! - [`AnyAuthProvider`] — OR semantics. Tries each child provider; the first +//! that contributes headers wins. Used when a CLI is configured with several +//! schemes but no per-endpoint security map (the default fallback). +//! - [`RoutingAuthProvider`] — per-endpoint dispatch. Reads the operation's +//! `security_requirements` (`security: [...]` in OpenAPI), finds the first +//! requirement that all registered providers can satisfy, and merges their +//! headers (AND inside a requirement, OR across requirements). +//! +//! Each scheme provider is parameterized by an [`AuthCredentialSource`] — a +//! lazy supplier that resolves a value from an env var, a literal, or a +//! closure. This mirrors the TS generator's `Supplier`. +//! +//! # Module layout +//! +//! - [`credential`] — `AuthCredentialSource` (lazy-supplier model with +//! env, CLI flag, file, literal, chain, and closure variants). +//! - [`provider`] — the [`AuthProvider`] trait, [`EndpointAuthMetadata`], +//! [`DynAuthProvider`] alias, and the [`NoAuthProvider`] sentinel. +//! - [`schemes`] — concrete [`BearerAuthProvider`], [`BasicAuthProvider`], +//! and [`HeaderAuthProvider`] implementations. +//! - [`compose`] — composition wrappers: [`AnyAuthProvider`], +//! [`AllAuthProvider`], [`LayeredAuthProvider`], [`RoutingAuthProvider`]. +//! - [`builder`] — [`SchemeBinding`], [`AuthStrategy`], and the +//! `build_provider_*` factories that `CliApp` calls. +//! - [`error`] — auth-aware HTTP error mapping (`handle_error_response`). +//! +//! All public types and functions are re-exported at the module root. + +pub mod builder; +pub mod compose; +pub mod credential; +pub mod error; +pub mod keyring_store; +pub mod login; +pub mod oauth2; +pub mod oauth2_contract; +pub mod oauth_common; +pub mod oauth_login; +pub mod provider; +pub mod root_builder; +pub mod schemes; + +#[cfg(test)] +pub(crate) mod test_helpers; + +pub use builder::{ + build_provider_from_bindings, build_provider_from_doc, build_provider_with_strategy, + collect_binding_cli_args, finalize_bindings, render_auth_help_section, render_auth_layers_help, + AuthStrategy, SchemeBinding, +}; +pub use compose::{AllAuthProvider, AnyAuthProvider, LayeredAuthProvider, RoutingAuthProvider}; +pub use credential::AuthCredentialSource; +pub use error::handle_error_response; +pub use keyring_store::{ + active_store, auto_store, set_active_store, FileKeyringStore, KeyringStore, MockKeyringStore, +}; +#[cfg(not(target_env = "musl"))] +pub use keyring_store::OsKeyringStore; +pub use login::{ + build_auth_command, dispatch_auth, inject_keyring_sources, inject_oauth2_caches, + run_token_paste, DynLoginFlow, LoginContext, LoginFlow, TokenPasteLoginFlow, +}; +pub use oauth2::{OAuth2Grant, OAuth2TokenProvider, TokenCache}; +pub use oauth2_contract::{OAuth2Endpoint, OAuth2RequestProperty, OAuth2RequestValue}; +pub use oauth_login::{DeviceCodeLoginFlow, OAuth2KeyringProvider, PkceLoginFlow, TokenBundle}; +pub use provider::{ + no_auth_provider, AuthProvider, DynAuthProvider, EndpointAuthMetadata, NoAuthProvider, +}; +pub use root_builder::{ApiKeyAuth, AuthSchemeBuilder, BasicAuth, BearerAuth, OAuth2Auth}; +pub use schemes::{BasicAuthProvider, BearerAuthProvider, HeaderAuthProvider}; diff --git a/src/auth/oauth2.rs b/src/auth/oauth2.rs new file mode 100644 index 0000000..46572d7 --- /dev/null +++ b/src/auth/oauth2.rs @@ -0,0 +1,1889 @@ +//! OAuth 2.0 auth provider with persistent token storage. +//! +//! [`OAuth2TokenProvider`] implements [`AuthProvider`] so it plugs directly into +//! the `auth_provider()` builder method on `CliApp`. On first `apply()`: +//! +//! 1. Check the on-disk credential cache (`~/.config//credentials.json`). +//! If a cached access token exists and hasn't expired, use it. +//! 2. If the cache holds a refresh token, exchange it for a new access token +//! (RFC 6749 §6) and update the cache. +//! 3. Otherwise fall back to the configured grant (client credentials or +//! refresh token from env) and persist the result. +//! +//! This mirrors the token persistence patterns used by `gcloud`, `gh`, and +//! `aws sso`. Tokens are stored as JSON with owner-only file permissions +//! (0600) and written atomically via temp-file-then-rename. +//! +//! For the async token fetch to work inside the synchronous `apply()` +//! method, the provider uses `tokio::task::block_in_place` + +//! `Handle::current().block_on()`. This is safe because `CliApp::run` +//! creates a multi-threaded tokio runtime. + +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; + +use secrecy::{ExposeSecret, SecretString}; +use serde::Serialize; +use serde_json::{Map, Value}; + +use crate::auth::oauth2_contract::{OAuth2BodyEncoding, OAuth2Endpoint, OAuth2RequestLocation}; +use crate::auth::oauth_common::{ + atomic_write, config_dir, now_epoch, parse_oauth_error_message, read_oauth_env, + token_http_client, truncate_body, TokenBundle, TokenSuccessBody, EXPIRY_BUFFER_SECS, +}; +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// On-disk token cache +// --------------------------------------------------------------------------- + +/// On-disk credential store at `~/.config//credentials.json`. +/// +/// The file is a JSON object keyed by token_url: +/// ```json +/// { +/// "https://identity.xero.com/connect/token": { +/// "access_token": "...", +/// "refresh_token": "...", +/// "expires_at": 1715550000 +/// } +/// } +/// ``` +/// +/// Coexists with the newer `FileKeyringStore` (`auth-keyring.json` in the +/// same directory) — same shape, distinct file. Login-flow providers use +/// the keyring store; legacy `OAuth2TokenProvider` callers (e.g. `xero`) +/// continue to use this cache via `.with_cache(...)`. +#[derive(Debug, Clone)] +pub struct TokenCache { + path: PathBuf, +} + +type TokenMap = std::collections::HashMap; + +impl TokenCache { + /// Build a cache path at `~/.config//credentials.json`. + pub fn for_cli(cli_name: &str) -> Option { + let dir = config_dir()?; + Some(Self { + path: dir.join(cli_name).join("credentials.json"), + }) + } + + /// Build a cache at an explicit path (for testing). + #[cfg(test)] + fn at_path(path: PathBuf) -> Self { + Self { path } + } + + fn read_map(&self) -> TokenMap { + let data = match std::fs::read_to_string(&self.path) { + Ok(d) => d, + Err(_) => return TokenMap::new(), + }; + serde_json::from_str(&data).unwrap_or_default() + } + + fn write_map(&self, map: &TokenMap) -> Result<(), CliError> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Auth(format!( + "Failed to create token cache directory {}: {e}", + parent.display() + )) + })?; + } + + let json = serde_json::to_string_pretty(map) + .map_err(|e| CliError::Auth(format!("Failed to serialize token cache: {e}")))?; + + atomic_write(&self.path, json.as_bytes()) + } + + /// Load a non-expired cached token for the given token_url. + fn load(&self, token_url: &str) -> Option { + let map = self.read_map(); + let entry = map.get(token_url)?; + if let Some(expires_at) = entry.expires_at { + if now_epoch() >= expires_at { + return None; + } + } + Some(entry.clone()) + } + + /// Persist a token response to disk. + fn store( + &self, + token_url: &str, + access_token: &str, + refresh_token: Option<&str>, + expires_in: Option, + ) -> Result<(), CliError> { + let mut map = self.read_map(); + let expires_at = expires_in.map(|ei| { + let buffered = ei.saturating_sub(EXPIRY_BUFFER_SECS); + now_epoch() + buffered + }); + // Preserve existing refresh_token if the new response didn't include one + let prev_refresh = map.get(token_url).and_then(|e| e.refresh_token.clone()); + map.insert( + token_url.to_string(), + TokenBundle { + access_token: access_token.to_string(), + refresh_token: refresh_token.map(|s| s.to_string()).or(prev_refresh), + expires_at, + }, + ); + self.write_map(&map) + } + + /// Remove the cached entry for a token_url (e.g., on refresh failure). + fn remove(&self, token_url: &str) { + let mut map = self.read_map(); + if map.remove(token_url).is_some() { + let _ = self.write_map(&map); + } + } +} + +// --------------------------------------------------------------------------- +// Grant configuration +// --------------------------------------------------------------------------- + +/// Which OAuth2 grant type to use. +#[derive(Debug, Clone)] +pub enum OAuth2Grant { + /// Client credentials grant (RFC 6749 §4.4). + ClientCredentials { + /// Env var name for the client ID. + client_id_env: String, + /// Env var name for the client secret. + client_secret_env: String, + /// Optional space-delimited scope string. + scope: Option, + }, + /// Refresh token grant (RFC 6749 §6). + RefreshToken { + /// Env var name for the client ID. + client_id_env: String, + /// Env var name for the client secret. + client_secret_env: String, + /// Env var name for the refresh token. + refresh_token_env: String, + }, +} + +// --------------------------------------------------------------------------- +// Form bodies (serde) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct ClientCredentialsForm<'a> { + grant_type: &'static str, + client_id: &'a str, + client_secret: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + scope: Option<&'a str>, +} + +#[derive(Serialize)] +struct RefreshTokenForm<'a> { + grant_type: &'static str, + client_id: &'a str, + client_secret: &'a str, + refresh_token: &'a str, +} + +// --------------------------------------------------------------------------- +// Token fetch +// --------------------------------------------------------------------------- + +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +async fn fetch_token(token_url: &str, grant: &OAuth2Grant) -> Result { + if token_url.trim().is_empty() { + return Err(CliError::Validation( + "OAuth2: token_url must not be empty".to_string(), + )); + } + + let http = token_http_client()?; + + let response = match grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + scope, + } => { + let client_id = read_env(client_id_env, "client_id")?; + let client_secret = read_env(client_secret_env, "client_secret")?; + http.post(token_url) + .form(&ClientCredentialsForm { + grant_type: "client_credentials", + client_id: &client_id, + client_secret: &client_secret, + scope: scope.as_deref(), + }) + .send() + .await + } + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => { + let client_id = read_env(client_id_env, "client_id")?; + let client_secret = read_env(client_secret_env, "client_secret")?; + let refresh_token = read_env(refresh_token_env, "refresh_token")?; + http.post(token_url) + .form(&RefreshTokenForm { + grant_type: "refresh_token", + client_id: &client_id, + client_secret: &client_secret, + refresh_token: &refresh_token, + }) + .send() + .await + } + } + .map_err(|e| CliError::Auth(format!("OAuth2 token request failed: {e}")))?; + + parse_token_response(response).await +} + +/// Exchange a cached refresh token for a new access token. +async fn refresh_cached_token( + token_url: &str, + client_id: &str, + client_secret: &str, + refresh_token: &str, +) -> Result { + let http = token_http_client()?; + let response = http + .post(token_url) + .form(&RefreshTokenForm { + grant_type: "refresh_token", + client_id, + client_secret, + refresh_token, + }) + .send() + .await + .map_err(|e| CliError::Auth(format!("OAuth2 token refresh failed: {e}")))?; + parse_token_response(response).await +} + +async fn parse_token_response(response: reqwest::Response) -> Result { + let status = response.status(); + let body_text = response + .text() + .await + .map_err(|e| CliError::Auth(format!("OAuth2 token response body: {e}")))?; + + if !status.is_success() { + let detail = + parse_oauth_error_message(&body_text).unwrap_or_else(|| truncate_body(&body_text)); + return Err(CliError::Auth(format!( + "OAuth2 token endpoint returned HTTP {status}: {detail}" + ))); + } + + let parsed: TokenSuccessBody = serde_json::from_str(&body_text).map_err(|e| { + CliError::Auth(format!( + "OAuth2 token response is not valid JSON with access_token: {e}" + )) + })?; + + if parsed.access_token.is_empty() { + return Err(CliError::Auth( + "OAuth2 token response contained an empty access_token".to_string(), + )); + } + + Ok(TokenResponse { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + expires_in: parsed.expires_in, + }) +} + +fn read_env(var: &str, label: &str) -> Result { + read_oauth_env(var, true, label)?.ok_or_else(|| { + CliError::Auth(format!( + "Environment variable {var} (OAuth2 {label}) must be non-empty" + )) + }) +} + +#[derive(Debug, Clone)] +struct OAuth2ClientCredentialsContract { + client_id_env: String, + client_secret_env: String, + scopes: Vec, + token_endpoint: OAuth2Endpoint, + refresh_endpoint: Option, +} + +async fn execute_contract_endpoint( + endpoint: &OAuth2Endpoint, + base_url_override: Option<&str>, + client_id: &str, + client_secret: &str, + scopes: &[String], + refresh_token: Option<&str>, +) -> Result { + let url = endpoint.resolve_url(base_url_override); + let method = reqwest::Method::from_bytes(endpoint.method.as_bytes()).map_err(|error| { + CliError::Auth(format!( + "OAuth2 token endpoint has invalid HTTP method '{}': {error}", + endpoint.method + )) + })?; + let http = token_http_client()?; + let mut request = http.request(method, &url); + let mut body = Map::new(); + let mut query = Vec::new(); + + for property in &endpoint.request_properties { + let Some(value) = + property + .value + .resolve(client_id, client_secret, scopes, refresh_token)? + else { + continue; + }; + match &property.location { + OAuth2RequestLocation::Body(path) => { + set_nested_value(&mut body, path, value)?; + } + OAuth2RequestLocation::Query { + name, + allow_multiple, + } => { + if *allow_multiple { + if let Value::Array(values) = value { + query.extend( + values + .iter() + .map(|value| (name.clone(), value_to_wire_string(value))), + ); + } else { + query.push((name.clone(), value_to_wire_string(&value))); + } + } else { + query.push((name.clone(), value_to_wire_string(&value))); + } + } + } + } + + if !query.is_empty() { + request = request.query(&query); + } + request = + match &endpoint.body_encoding { + OAuth2BodyEncoding::None => request, + OAuth2BodyEncoding::Json(content_type) => request + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(serde_json::to_vec(&Value::Object(body)).map_err(|error| { + CliError::Auth(format!("OAuth2 token request body: {error}")) + })?), + OAuth2BodyEncoding::Form => { + let form = body + .into_iter() + .map(|(name, value)| (name, value_to_wire_string(&value))) + .collect::>(); + request.form(&form) + } + }; + + let response = request + .send() + .await + .map_err(|error| CliError::Auth(format!("OAuth2 token request failed: {error}")))?; + parse_contract_response(response, endpoint).await +} + +async fn parse_contract_response( + response: reqwest::Response, + endpoint: &OAuth2Endpoint, +) -> Result { + let status = response.status(); + let body_text = response + .text() + .await + .map_err(|error| CliError::Auth(format!("OAuth2 token response body: {error}")))?; + if !status.is_success() { + let detail = + parse_oauth_error_message(&body_text).unwrap_or_else(|| truncate_body(&body_text)); + return Err(CliError::Auth(format!( + "OAuth2 token endpoint returned HTTP {status}: {detail}" + ))); + } + let body: Value = serde_json::from_str(&body_text).map_err(|error| { + CliError::Auth(format!("OAuth2 token response is not valid JSON: {error}")) + })?; + let access_token = value_at_path(&body, &endpoint.access_token_path) + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .ok_or_else(|| { + CliError::Auth(format!( + "OAuth2 token response is missing a non-empty access token at '{}'", + endpoint.access_token_path.join(".") + )) + })? + .to_string(); + let expires_in = endpoint + .expires_in_path + .as_deref() + .and_then(|path| value_at_path(&body, path)) + .and_then(parse_u64); + let refresh_token = endpoint + .refresh_token_path + .as_deref() + .and_then(|path| value_at_path(&body, path)) + .and_then(Value::as_str) + .filter(|token| !token.is_empty()) + .map(str::to_string); + Ok(TokenResponse { + access_token, + refresh_token, + expires_in, + }) +} + +fn set_nested_value( + body: &mut Map, + path: &[String], + value: Value, +) -> Result<(), CliError> { + let Some((last, parents)) = path.split_last() else { + return Err(CliError::Auth( + "OAuth2 token request property has an empty body path".to_string(), + )); + }; + let mut current = body; + for part in parents { + let entry = current + .entry(part.clone()) + .or_insert_with(|| Value::Object(Map::new())); + current = entry.as_object_mut().ok_or_else(|| { + CliError::Auth(format!( + "OAuth2 token request body path '{}' conflicts with another property", + path.join(".") + )) + })?; + } + current.insert(last.clone(), value); + Ok(()) +} + +fn value_to_wire_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Array(values) => values + .iter() + .map(value_to_wire_string) + .collect::>() + .join(" "), + other => other.to_string(), + } +} + +fn value_at_path<'a>(value: &'a Value, path: &[String]) -> Option<&'a Value> { + path.iter() + .try_fold(value, |current, segment| current.get(segment)) +} + +fn parse_u64(value: &Value) -> Option { + value + .as_u64() + .or_else(|| value.as_str().and_then(|value| value.parse().ok())) +} + +// --------------------------------------------------------------------------- +// OAuth2TokenProvider +// --------------------------------------------------------------------------- + +/// OAuth2 auth provider with on-disk token persistence. +/// +/// Resolution order on each `apply()`: +/// 1. In-process cache (`OnceLock`) — already resolved this invocation. +/// 2. On-disk cache — non-expired access token from a previous invocation. +/// 3. Cached refresh token — exchange for a new access token. +/// 4. Configured grant (client credentials or env-based refresh token). +/// +/// New tokens are persisted to `~/.config//credentials.json` (Linux), +/// `~/Library/Application Support//credentials.json` (macOS), or +/// `%APPDATA%//credentials.json` (Windows). +pub struct OAuth2TokenProvider { + scheme_name: String, + token_url: String, + grant: OAuth2Grant, + contract: Option, + token_header: String, + token_prefix: String, + cache: OnceLock, + cached_tokens: Mutex, +} + +impl std::fmt::Debug for OAuth2TokenProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2TokenProvider") + .field("scheme_name", &self.scheme_name) + .field("token_url", &self.token_url) + .field("grant", &self.grant) + .field("contract", &self.contract) + .field("token_header", &self.token_header) + .field("token_prefix", &self.token_prefix) + .finish() + } +} + +impl OAuth2TokenProvider { + pub fn new( + scheme_name: impl Into, + token_url: impl Into, + grant: OAuth2Grant, + ) -> Self { + Self { + scheme_name: scheme_name.into(), + token_url: token_url.into(), + grant, + contract: None, + token_header: "Authorization".to_string(), + token_prefix: "Bearer".to_string(), + cache: OnceLock::new(), + cached_tokens: Mutex::new(TokenMap::new()), + } + } + + pub fn from_client_credentials( + scheme_name: impl Into, + client_id_env: impl Into, + client_secret_env: impl Into, + scopes: Vec, + token_endpoint: OAuth2Endpoint, + refresh_endpoint: Option, + token_header: impl Into, + token_prefix: impl Into, + ) -> Self { + let client_id_env = client_id_env.into(); + let client_secret_env = client_secret_env.into(); + Self { + scheme_name: scheme_name.into(), + token_url: token_endpoint.default_url.clone(), + grant: OAuth2Grant::ClientCredentials { + client_id_env: client_id_env.clone(), + client_secret_env: client_secret_env.clone(), + scope: if scopes.is_empty() { + None + } else { + Some(scopes.join(" ")) + }, + }, + contract: Some(OAuth2ClientCredentialsContract { + client_id_env, + client_secret_env, + scopes, + token_endpoint, + refresh_endpoint, + }), + token_header: token_header.into(), + token_prefix: token_prefix.into(), + cache: OnceLock::new(), + cached_tokens: Mutex::new(TokenMap::new()), + } + } + + pub fn with_token_application( + mut self, + header: impl Into, + prefix: impl Into, + ) -> Self { + self.token_header = header.into(); + self.token_prefix = prefix.into(); + self + } + + /// Enable on-disk token persistence. `cli_name` is the binary name + /// (e.g., `"xero"`) — tokens are stored under the platform config dir. + pub fn with_cache(self, cli_name: &str) -> Self { + if let Some(tc) = TokenCache::for_cli(cli_name) { + let _ = self.cache.set(tc); + } + self + } + + /// Enable on-disk token persistence with a pre-built [`TokenCache`]. + pub fn with_token_cache(self, cache: TokenCache) -> Self { + let _ = self.cache.set(cache); + self + } + + /// Returns `true` if on-disk token caching has been wired. + pub fn has_cache(&self) -> bool { + self.cache.get().is_some() + } + + fn resolve_token(&self, endpoint: &EndpointAuthMetadata) -> Result { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.resolve_token_async(endpoint)) + .map(SecretString::from) + }) + } + + async fn resolve_token_async( + &self, + endpoint: &EndpointAuthMetadata, + ) -> Result { + let token_url = self.resolved_token_url(endpoint); + if let Some(cached) = self.load_in_process(&token_url) { + return Ok(cached.access_token); + } + if let Some(token) = self.try_in_process_refresh(endpoint, &token_url).await { + return Ok(token); + } + + if let Some(cache) = self.cache.get() { + if let Some(cached) = cache.load(&token_url) { + tracing::debug!("Using cached OAuth2 access token for {}", token_url); + self.store_in_process(&token_url, cached.clone()); + return Ok(cached.access_token); + } + + if let Some(token_resp) = self.try_cached_refresh(cache, endpoint, &token_url).await { + return Ok(token_resp); + } + } + + let resp = self.fetch_configured_token(endpoint, &token_url).await?; + self.persist_response(&token_url, &resp); + Ok(resp.access_token) + } + + async fn try_in_process_refresh( + &self, + endpoint: &EndpointAuthMetadata, + token_url: &str, + ) -> Option { + let refresh_token = self + .cached_tokens + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(token_url) + .and_then(|entry| entry.refresh_token.clone())?; + let contract = self.contract.as_ref()?; + let refresh_endpoint = contract.refresh_endpoint.as_ref()?; + let client_id = read_env(&contract.client_id_env, "client_id").ok()?; + let client_secret = read_env(&contract.client_secret_env, "client_secret").ok()?; + match execute_contract_endpoint( + refresh_endpoint, + endpoint.base_url_override.as_deref(), + &client_id, + &client_secret, + &contract.scopes, + Some(&refresh_token), + ) + .await + { + Ok(resp) => { + self.persist_response(token_url, &resp); + Some(resp.access_token) + } + Err(error) => { + tracing::debug!("In-process OAuth2 refresh failed, falling through: {error}"); + self.cached_tokens + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(token_url); + None + } + } + } + + async fn try_cached_refresh( + &self, + cache: &TokenCache, + endpoint: &EndpointAuthMetadata, + token_url: &str, + ) -> Option { + let map = cache.read_map(); + let entry = map.get(token_url)?; + let refresh_token = entry.refresh_token.as_deref()?; + + let result = if let Some(contract) = &self.contract { + let refresh_endpoint = contract.refresh_endpoint.as_ref()?; + let client_id = read_env(&contract.client_id_env, "client_id").ok()?; + let client_secret = read_env(&contract.client_secret_env, "client_secret").ok()?; + execute_contract_endpoint( + refresh_endpoint, + endpoint.base_url_override.as_deref(), + &client_id, + &client_secret, + &contract.scopes, + Some(refresh_token), + ) + .await + } else { + let (client_id_env, client_secret_env) = grant_credential_envs(&self.grant); + let client_id = read_env(client_id_env, "client_id").ok()?; + let client_secret = read_env(client_secret_env, "client_secret").ok()?; + refresh_cached_token(token_url, &client_id, &client_secret, refresh_token).await + }; + + match result { + Ok(resp) => { + self.persist_response(token_url, &resp); + Some(resp.access_token) + } + Err(e) => { + tracing::debug!("Cached refresh token failed, falling through: {e}"); + cache.remove(token_url); + self.cached_tokens + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(token_url); + None + } + } + } + + async fn fetch_configured_token( + &self, + endpoint: &EndpointAuthMetadata, + token_url: &str, + ) -> Result { + if let Some(contract) = &self.contract { + let client_id = read_env(&contract.client_id_env, "client_id")?; + let client_secret = read_env(&contract.client_secret_env, "client_secret")?; + execute_contract_endpoint( + &contract.token_endpoint, + endpoint.base_url_override.as_deref(), + &client_id, + &client_secret, + &contract.scopes, + None, + ) + .await + } else { + fetch_token(token_url, &self.grant).await + } + } + + fn resolved_token_url(&self, endpoint: &EndpointAuthMetadata) -> String { + self.contract + .as_ref() + .map(|contract| { + contract + .token_endpoint + .resolve_url(endpoint.base_url_override.as_deref()) + }) + .unwrap_or_else(|| self.token_url.clone()) + } + + fn load_in_process(&self, token_url: &str) -> Option { + let map = self + .cached_tokens + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = map.get(token_url)?; + if entry + .expires_at + .is_some_and(|expires_at| now_epoch() >= expires_at) + { + return None; + } + Some(entry.clone()) + } + + fn store_in_process(&self, token_url: &str, bundle: TokenBundle) { + self.cached_tokens + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(token_url.to_string(), bundle); + } + + fn persist_response(&self, token_url: &str, resp: &TokenResponse) { + let expires_at = resp + .expires_in + .map(|expires_in| now_epoch() + expires_in.saturating_sub(EXPIRY_BUFFER_SECS)); + let previous_refresh = self + .cached_tokens + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(token_url) + .and_then(|entry| entry.refresh_token.clone()); + self.store_in_process( + token_url, + TokenBundle { + access_token: resp.access_token.clone(), + refresh_token: resp.refresh_token.clone().or(previous_refresh), + expires_at, + }, + ); + if let Some(cache) = self.cache.get() { + if let Err(e) = cache.store( + token_url, + &resp.access_token, + resp.refresh_token.as_deref(), + resp.expires_in, + ) { + tracing::warn!("Failed to persist OAuth2 token to cache: {e}"); + } + } + } +} + +fn grant_credential_envs(grant: &OAuth2Grant) -> (&str, &str) { + match grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } + | OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + .. + } => (client_id_env, client_secret_env), + } +} + +impl AuthProvider for OAuth2TokenProvider { + fn name(&self) -> &str { + &self.scheme_name + } + + fn has_credentials(&self) -> bool { + self.has_credentials_for_url(&self.token_url) + } + + fn has_credentials_for(&self, endpoint: &EndpointAuthMetadata) -> bool { + self.has_credentials_for_url(&self.resolved_token_url(endpoint)) + } + + fn credential_hints(&self) -> Vec { + if let Some(contract) = &self.contract { + let mut env_vars = vec![ + contract.client_id_env.as_str(), + contract.client_secret_env.as_str(), + ]; + env_vars.extend(contract.token_endpoint.required_env_vars()); + if let Some(refresh_endpoint) = &contract.refresh_endpoint { + env_vars.extend(refresh_endpoint.required_env_vars()); + } + env_vars.sort_unstable(); + env_vars.dedup(); + return env_vars + .into_iter() + .map(|env_var| format!("{env_var} environment variable")) + .collect(); + } + match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => vec![ + format!("{client_id_env} environment variable"), + format!("{client_secret_env} environment variable"), + ], + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => vec![ + format!("{client_id_env} environment variable"), + format!("{client_secret_env} environment variable"), + format!("{refresh_token_env} environment variable"), + ], + } + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result { + let token = self.resolve_token(endpoint)?; + let exposed = token.expose_secret(); + let value = if self.token_prefix.is_empty() { + exposed.to_string() + } else { + format!("{} {exposed}", self.token_prefix) + }; + let header_name = reqwest::header::HeaderName::from_bytes(self.token_header.as_bytes()) + .map_err(|error| { + CliError::Auth(format!( + "Invalid OAuth2 token header '{}': {error}", + self.token_header + )) + })?; + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|error| CliError::Auth(format!("Invalid OAuth2 access token: {error}")))?; + header.set_sensitive(true); + Ok(request.header(header_name, header)) + } + + fn inject_token_cache(&self, cli_name: &str) { + if let Some(tc) = TokenCache::for_cli(cli_name) { + let _ = self.cache.set(tc); + } + } +} + +impl OAuth2TokenProvider { + fn has_credentials_for_url(&self, token_url: &str) -> bool { + if self.load_in_process(token_url).is_some() { + return true; + } + if let Some(cache) = self.cache.get() { + if cache.load(token_url).is_some() { + return true; + } + let map = cache.read_map(); + if let Some(entry) = map.get(token_url) { + if entry.refresh_token.is_some() + && self + .contract + .as_ref() + .is_some_and(|contract| contract.refresh_endpoint.is_some()) + { + return true; + } + } + } + if let Some(contract) = &self.contract { + return env_is_set(&contract.client_id_env) + && env_is_set(&contract.client_secret_env) + && contract.token_endpoint.required_env_vars().all(env_is_set); + } + match &self.grant { + OAuth2Grant::ClientCredentials { + client_id_env, + client_secret_env, + .. + } => env_is_set(client_id_env) && env_is_set(client_secret_env), + OAuth2Grant::RefreshToken { + client_id_env, + client_secret_env, + refresh_token_env, + } => { + env_is_set(client_id_env) + && env_is_set(client_secret_env) + && env_is_set(refresh_token_env) + } + } + } +} + +fn env_is_set(var: &str) -> bool { + std::env::var(var) + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +/// Fail-fast provider for an OAuth2 scheme that was declared (via +/// [`OAuth2Auth`](crate::auth::OAuth2Auth)) but is missing the config needed +/// to obtain a token — e.g. no `token_url`, or client credentials supplied +/// from a non-env source the [`OAuth2Grant`] env-var model can't read. +/// +/// The point is to **never silently send an unauthenticated request** +/// (FER-10745). [`has_credentials`](AuthProvider::has_credentials) returns +/// `true` so composition wrappers select this provider rather than skipping +/// it, and [`apply`](AuthProvider::apply) then errors with a clear message +/// instead of letting the request go out with no `Authorization` header. +#[derive(Debug)] +pub(crate) struct MisconfiguredOAuth2Provider { + scheme_name: String, + reason: String, +} + +impl MisconfiguredOAuth2Provider { + pub(crate) fn new(scheme_name: impl Into, reason: impl Into) -> Self { + Self { + scheme_name: scheme_name.into(), + reason: reason.into(), + } + } +} + +impl AuthProvider for MisconfiguredOAuth2Provider { + fn name(&self) -> &str { + &self.scheme_name + } + + // Report credentials as present so wrappers don't skip this provider + // (skipping would fall through to an unauthenticated request — the bug). + fn has_credentials(&self) -> bool { + true + } + + fn credential_hints(&self) -> Vec { + vec![self.reason.clone()] + } + + fn apply( + &self, + _request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + Err(CliError::Auth(format!( + "OAuth2 scheme '{}' is configured but cannot obtain a token: {}. \ + Refusing to send an unauthenticated request.", + self.scheme_name, self.reason, + ))) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::oauth2_contract::{OAuth2RequestProperty, OAuth2RequestValue}; + use crate::auth::test_helpers::{auth_header, header as request_header, req}; + use serial_test::serial; + use wiremock::matchers::{body_json, body_string_contains, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_fetches_and_caches_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "cc-token-123", + "token_type": "Bearer" + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CC_ID", "my-id"); + std::env::set_var("TEST_CC_SECRET", "my-secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CC_ID".to_string(), + client_secret_env: "TEST_CC_SECRET".to_string(), + scope: None, + }, + ); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer cc-token-123")); + + // Second call uses in-process cache (wiremock expect(1) would fail otherwise) + let r2 = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r2).as_deref(), Some("Bearer cc-token-123")); + + std::env::remove_var("TEST_CC_ID"); + std::env::remove_var("TEST_CC_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn ir_contract_executes_custom_request_and_response_mappings() { + let server = MockServer::start().await; + Mock::given(method("PUT")) + .and(path("/oauth/token")) + .and(query_param("audience", "api")) + .and(query_param("region", "us")) + .and(query_param("region", "eu")) + .and(body_json(serde_json::json!({ + "credentials": { + "id": "contract-id", + "secret": "contract-secret" + }, + "permissions": ["read:pets", "write:pets"], + "grant_type": "client_credentials", + "tenant": "fern" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": { + "token": "mapped-token", + "ttl": "3600" + } + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CONTRACT_ID", "contract-id"); + std::env::set_var("TEST_CONTRACT_SECRET", "contract-secret"); + std::env::set_var("TEST_CONTRACT_GRANT", "client_credentials"); + std::env::set_var("TEST_CONTRACT_TENANT", "fern"); + + let endpoint = OAuth2Endpoint::new(format!("{}/oauth/token", server.uri()), "/oauth/token") + .method("PUT") + .json_body("application/json") + .request_property(OAuth2RequestProperty::body( + ["credentials", "id"], + OAuth2RequestValue::ClientId, + )) + .request_property(OAuth2RequestProperty::body( + ["credentials", "secret"], + OAuth2RequestValue::ClientSecret, + )) + .request_property(OAuth2RequestProperty::body( + ["permissions"], + OAuth2RequestValue::ScopesList, + )) + .request_property(OAuth2RequestProperty::body( + ["grant_type"], + OAuth2RequestValue::env("TEST_CONTRACT_GRANT", true), + )) + .request_property(OAuth2RequestProperty::body( + ["tenant"], + OAuth2RequestValue::env("TEST_CONTRACT_TENANT", false), + )) + .request_property(OAuth2RequestProperty::query( + "audience", + OAuth2RequestValue::literal(serde_json::json!("api")), + )) + .request_property(OAuth2RequestProperty::query_multiple( + "region", + OAuth2RequestValue::literal(serde_json::json!(["us", "eu"])), + )) + .request_property(OAuth2RequestProperty::query( + "hint", + OAuth2RequestValue::optional_env("TEST_CONTRACT_HINT", false), + )) + .access_token_path(["result", "token"]) + .expires_in_path(["result", "ttl"]); + let provider = OAuth2TokenProvider::from_client_credentials( + "oauth2", + "TEST_CONTRACT_ID", + "TEST_CONTRACT_SECRET", + vec!["read:pets".to_string(), "write:pets".to_string()], + endpoint, + None, + "X-Session-Token", + "", + ); + + let request = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!( + request_header(request, "x-session-token").as_deref(), + Some("mapped-token") + ); + + std::env::remove_var("TEST_CONTRACT_ID"); + std::env::remove_var("TEST_CONTRACT_SECRET"); + std::env::remove_var("TEST_CONTRACT_GRANT"); + std::env::remove_var("TEST_CONTRACT_TENANT"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn ir_contract_uses_runtime_base_url_override() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "override-token" + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_OVERRIDE_ID", "id"); + std::env::set_var("TEST_OVERRIDE_SECRET", "secret"); + let endpoint = OAuth2Endpoint::new("https://default.invalid/token", "/token") + .use_base_url_override() + .form_body() + .request_property(OAuth2RequestProperty::body( + ["client_id"], + OAuth2RequestValue::ClientId, + )) + .request_property(OAuth2RequestProperty::body( + ["client_secret"], + OAuth2RequestValue::ClientSecret, + )); + let provider = OAuth2TokenProvider::from_client_credentials( + "oauth2", + "TEST_OVERRIDE_ID", + "TEST_OVERRIDE_SECRET", + Vec::new(), + endpoint, + None, + "Authorization", + "Bearer", + ); + let metadata = + EndpointAuthMetadata::unspecified().with_base_url_override(Some(&server.uri())); + let request = provider.apply(req(), &metadata).unwrap(); + assert_eq!( + auth_header(request).as_deref(), + Some("Bearer override-token") + ); + + std::env::remove_var("TEST_OVERRIDE_ID"); + std::env::remove_var("TEST_OVERRIDE_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn ir_contract_refreshes_with_distinct_endpoint() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "initial-token", + "refresh_token": "refresh-me", + "expires_in": 1 + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/refresh")) + .and(body_json(serde_json::json!({ + "refresh": "refresh-me", + "grant_type": "refresh_token" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-token", + "expires_in": 3600 + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_REFRESH_ID", "id"); + std::env::set_var("TEST_REFRESH_SECRET", "secret"); + let token_endpoint = OAuth2Endpoint::new(format!("{}/token", server.uri()), "/token") + .json_body("application/json") + .request_property(OAuth2RequestProperty::body( + ["client_id"], + OAuth2RequestValue::ClientId, + )) + .request_property(OAuth2RequestProperty::body( + ["client_secret"], + OAuth2RequestValue::ClientSecret, + )) + .expires_in_path(["expires_in"]) + .refresh_token_path(["refresh_token"]); + let refresh_endpoint = OAuth2Endpoint::new(format!("{}/refresh", server.uri()), "/refresh") + .json_body("application/json") + .request_property(OAuth2RequestProperty::body( + ["refresh"], + OAuth2RequestValue::RefreshToken, + )) + .request_property(OAuth2RequestProperty::body( + ["grant_type"], + OAuth2RequestValue::literal(serde_json::json!("refresh_token")), + )) + .expires_in_path(["expires_in"]); + let provider = OAuth2TokenProvider::from_client_credentials( + "oauth2", + "TEST_REFRESH_ID", + "TEST_REFRESH_SECRET", + Vec::new(), + token_endpoint, + Some(refresh_endpoint), + "Authorization", + "Bearer", + ); + let first = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(first).as_deref(), Some("Bearer initial-token")); + let second = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!( + auth_header(second).as_deref(), + Some("Bearer refreshed-token") + ); + + std::env::remove_var("TEST_REFRESH_ID"); + std::env::remove_var("TEST_REFRESH_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_sends_requested_scopes() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains("scope=read+write")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "scoped-token", + "token_type": "Bearer" + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_SCOPE_ID", "my-id"); + std::env::set_var("TEST_SCOPE_SECRET", "my-secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_SCOPE_ID".to_string(), + client_secret_env: "TEST_SCOPE_SECRET".to_string(), + scope: Some("read write".to_string()), + }, + ); + + let request = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(request).as_deref(), Some("Bearer scoped-token")); + + std::env::remove_var("TEST_SCOPE_ID"); + std::env::remove_var("TEST_SCOPE_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn client_credentials_no_creds_when_env_unset() { + std::env::remove_var("MISSING_CC_ID_XYZ"); + std::env::remove_var("MISSING_CC_SECRET_XYZ"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://unused.example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "MISSING_CC_ID_XYZ".to_string(), + client_secret_env: "MISSING_CC_SECRET_XYZ".to_string(), + scope: None, + }, + ); + + assert!(!provider.has_credentials()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn refresh_token_fetches_token() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-token-456", + "token_type": "Bearer" + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_RT_ID", "my-id"); + std::env::set_var("TEST_RT_SECRET", "my-secret"); + std::env::set_var("TEST_RT_REFRESH", "my-refresh-token"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + format!("{}/token", server.uri()), + OAuth2Grant::RefreshToken { + client_id_env: "TEST_RT_ID".to_string(), + client_secret_env: "TEST_RT_SECRET".to_string(), + refresh_token_env: "TEST_RT_REFRESH".to_string(), + }, + ); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Bearer refreshed-token-456") + ); + + std::env::remove_var("TEST_RT_ID"); + std::env::remove_var("TEST_RT_SECRET"); + std::env::remove_var("TEST_RT_REFRESH"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn refresh_token_no_creds_without_refresh_env() { + std::env::set_var("TEST_RT_ID2", "id"); + std::env::set_var("TEST_RT_SECRET2", "secret"); + std::env::remove_var("MISSING_RT_XYZ"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://unused.example.com/token", + OAuth2Grant::RefreshToken { + client_id_env: "TEST_RT_ID2".to_string(), + client_secret_env: "TEST_RT_SECRET2".to_string(), + refresh_token_env: "MISSING_RT_XYZ".to_string(), + }, + ); + + assert!(!provider.has_credentials()); + + std::env::remove_var("TEST_RT_ID2"); + std::env::remove_var("TEST_RT_SECRET2"); + } + + // `parse_oauth_error_message` + `truncate_body` are tested in + // `oauth_common::tests` — no need to duplicate here. + + // ---- Token cache tests ---- + + #[test] + fn token_cache_store_and_load() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store( + "https://example.com/token", + "access-abc", + Some("refresh-xyz"), + Some(3600), + ) + .unwrap(); + + let loaded = cache.load("https://example.com/token").unwrap(); + assert_eq!(loaded.access_token, "access-abc"); + assert_eq!(loaded.refresh_token.as_deref(), Some("refresh-xyz")); + assert!(loaded.expires_at.is_some()); + } + + #[test] + fn token_cache_expired_token_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Store a token with 0 seconds expiry (immediately expired after buffer) + cache + .store("https://example.com/token", "expired", None, Some(0)) + .unwrap(); + + assert!(cache.load("https://example.com/token").is_none()); + } + + #[test] + fn token_cache_no_expiry_always_valid() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "forever", None, None) + .unwrap(); + + let loaded = cache.load("https://example.com/token").unwrap(); + assert_eq!(loaded.access_token, "forever"); + assert!(loaded.expires_at.is_none()); + } + + #[test] + fn token_cache_remove() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "abc", None, Some(3600)) + .unwrap(); + assert!(cache.load("https://example.com/token").is_some()); + + cache.remove("https://example.com/token"); + assert!(cache.load("https://example.com/token").is_none()); + } + + #[test] + fn token_cache_preserves_refresh_token_on_update() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Initial store with refresh token + cache + .store( + "https://ex.com/t", + "old-access", + Some("my-refresh"), + Some(3600), + ) + .unwrap(); + + // Update with new access token but no refresh token in response + cache + .store("https://ex.com/t", "new-access", None, Some(3600)) + .unwrap(); + + let loaded = cache.load("https://ex.com/t").unwrap(); + assert_eq!(loaded.access_token, "new-access"); + assert_eq!(loaded.refresh_token.as_deref(), Some("my-refresh")); + } + + #[cfg(unix)] + #[test] + fn token_cache_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("credentials.json"); + let cache = TokenCache::at_path(path.clone()); + + cache + .store("https://example.com/token", "secret", None, None) + .unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "Token cache should be owner-only"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_disk_cache() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + // Pre-populate the cache + cache + .store( + "https://example.com/token", + "cached-token", + None, + Some(3600), + ) + .unwrap(); + + // Provider should not hit the network (no MockServer needed) + std::env::set_var("TEST_CACHE_ID", "id"); + std::env::set_var("TEST_CACHE_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CACHE_ID".to_string(), + client_secret_env: "TEST_CACHE_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache); + + assert!(provider.has_credentials()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer cached-token")); + + std::env::remove_var("TEST_CACHE_ID"); + std::env::remove_var("TEST_CACHE_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_persists_token_to_disk() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "new-token", + "refresh_token": "new-refresh", + "expires_in": 3600 + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_PERSIST_ID", "id"); + std::env::set_var("TEST_PERSIST_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_PERSIST_ID".to_string(), + client_secret_env: "TEST_PERSIST_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer new-token")); + + // Verify it was persisted + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "new-token"); + assert_eq!(loaded.refresh_token.as_deref(), Some("new-refresh")); + + std::env::remove_var("TEST_PERSIST_ID"); + std::env::remove_var("TEST_PERSIST_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_cached_refresh_token() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + // Pre-populate cache with expired access + valid refresh + { + let mut map = TokenMap::new(); + map.insert( + token_url.clone(), + TokenBundle { + access_token: "expired".to_string(), + refresh_token: Some("cached-refresh".to_string()), + expires_at: Some(0), // already expired + }, + ); + let json = serde_json::to_string_pretty(&map).unwrap(); + std::fs::write(dir.path().join("credentials.json"), json).unwrap(); + } + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "refreshed-from-cache", + "refresh_token": "new-refresh", + "expires_in": 7200 + }))) + .expect(1) + .mount(&server) + .await; + + std::env::set_var("TEST_CREFRESH_ID", "id"); + std::env::set_var("TEST_CREFRESH_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_CREFRESH_ID".to_string(), + client_secret_env: "TEST_CREFRESH_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!( + auth_header(r).as_deref(), + Some("Bearer refreshed-from-cache") + ); + + // Verify the new tokens were persisted + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "refreshed-from-cache"); + assert_eq!(loaded.refresh_token.as_deref(), Some("new-refresh")); + + std::env::remove_var("TEST_CREFRESH_ID"); + std::env::remove_var("TEST_CREFRESH_SECRET"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_falls_through_when_cached_refresh_fails() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + let server = MockServer::start().await; + let token_url = format!("{}/token", server.uri()); + + // Pre-populate cache with expired access + stale refresh + { + let mut map = TokenMap::new(); + map.insert( + token_url.clone(), + TokenBundle { + access_token: "expired".to_string(), + refresh_token: Some("stale-refresh".to_string()), + expires_at: Some(0), + }, + ); + let json = serde_json::to_string_pretty(&map).unwrap(); + std::fs::write(dir.path().join("credentials.json"), json).unwrap(); + } + + // First call (refresh) fails, second call (client credentials) succeeds + let call_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let call_count_clone = call_count.clone(); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(move |_req: &wiremock::Request| { + let n = call_count_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + // Refresh fails + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "refresh token expired" + })) + } else { + // Client credentials succeeds + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fresh-cc-token", + "expires_in": 3600 + })) + } + }) + .expect(2) + .mount(&server) + .await; + + std::env::set_var("TEST_FALLTHRU_ID", "id"); + std::env::set_var("TEST_FALLTHRU_SECRET", "secret"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + &token_url, + OAuth2Grant::ClientCredentials { + client_id_env: "TEST_FALLTHRU_ID".to_string(), + client_secret_env: "TEST_FALLTHRU_SECRET".to_string(), + scope: None, + }, + ) + .with_token_cache(cache.clone()); + + let r = provider + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer fresh-cc-token")); + + // The stale refresh token should have been removed + let loaded = cache.load(&token_url).unwrap(); + assert_eq!(loaded.access_token, "fresh-cc-token"); + assert!(loaded.refresh_token.is_none()); + + std::env::remove_var("TEST_FALLTHRU_ID"); + std::env::remove_var("TEST_FALLTHRU_SECRET"); + } + + #[test] + fn has_credentials_true_when_cache_has_valid_token() { + let dir = tempfile::tempdir().unwrap(); + let cache = TokenCache::at_path(dir.path().join("credentials.json")); + + cache + .store("https://example.com/token", "valid", None, Some(3600)) + .unwrap(); + + std::env::remove_var("NO_SUCH_ID_XYZ_TOKEN_TEST"); + std::env::remove_var("NO_SUCH_SECRET_XYZ_TOKEN_TEST"); + + let provider = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "NO_SUCH_ID_XYZ_TOKEN_TEST".to_string(), + client_secret_env: "NO_SUCH_SECRET_XYZ_TOKEN_TEST".to_string(), + scope: None, + }, + ) + .with_token_cache(cache); + + // has_credentials is true because of disk cache, even though env vars are unset + assert!(provider.has_credentials()); + } + + #[test] + fn with_cache_sets_oncelock() { + let p = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "X".to_string(), + client_secret_env: "Y".to_string(), + scope: None, + }, + ); + assert!(!p.has_cache()); + let p = p.with_cache("my-test-cli"); + assert!(p.has_cache()); + } + + #[test] + fn inject_token_cache_sets_cache_via_trait() { + use crate::auth::provider::AuthProvider; + let p = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "X".to_string(), + client_secret_env: "Y".to_string(), + scope: None, + }, + ); + assert!(!p.has_cache()); + p.inject_token_cache("my-test-cli"); + assert!(p.has_cache()); + } + + #[test] + fn inject_token_cache_idempotent() { + use crate::auth::provider::AuthProvider; + let p = OAuth2TokenProvider::new( + "oauth2", + "https://example.com/token", + OAuth2Grant::ClientCredentials { + client_id_env: "X".to_string(), + client_secret_env: "Y".to_string(), + scope: None, + }, + ); + p.inject_token_cache("first-cli"); + assert!(p.has_cache()); + // Second call is a no-op (OnceLock already set) + p.inject_token_cache("second-cli"); + assert!(p.has_cache()); + } + + // FER-10745: a misconfigured OAuth2 scheme must error loudly, never let an + // unauthenticated request through. + #[tokio::test(flavor = "multi_thread")] + async fn misconfigured_oauth2_provider_errors_instead_of_silent_unauth() { + let provider = + MisconfiguredOAuth2Provider::new("OAuth2Security", "missing OAuth2 config: token_url"); + + // Selected by composition (not skipped) so the failure surfaces. + assert!(provider.has_credentials()); + + let request = reqwest::Client::new().get("https://api.example.com/v1/thing"); + let result = provider.apply(request, &EndpointAuthMetadata::unspecified()); + let err = result.expect_err("misconfigured OAuth2 must refuse to send"); + assert!(matches!(err, CliError::Auth(_))); + let msg = err.to_string(); + assert!( + msg.contains("OAuth2Security") && msg.contains("token_url"), + "error should name the scheme and the missing config: {msg}", + ); + } +} diff --git a/src/auth/oauth2_contract.rs b/src/auth/oauth2_contract.rs new file mode 100644 index 0000000..14abcdd --- /dev/null +++ b/src/auth/oauth2_contract.rs @@ -0,0 +1,249 @@ +use serde_json::Value; + +use crate::auth::oauth_common::read_oauth_env; +use crate::error::CliError; + +#[derive(Debug, Clone)] +pub enum OAuth2RequestValue { + ClientId, + ClientSecret, + Scopes, + ScopesList, + RefreshToken, + Literal(Value), + Env { + name: String, + parse_json: bool, + required: bool, + }, +} + +impl OAuth2RequestValue { + pub fn literal(value: Value) -> Self { + Self::Literal(value) + } + + pub fn env(name: impl Into, parse_json: bool) -> Self { + Self::Env { + name: name.into(), + parse_json, + required: true, + } + } + + pub fn optional_env(name: impl Into, parse_json: bool) -> Self { + Self::Env { + name: name.into(), + parse_json, + required: false, + } + } + + pub(crate) fn resolve( + &self, + client_id: &str, + client_secret: &str, + scopes: &[String], + refresh_token: Option<&str>, + ) -> Result, CliError> { + match self { + Self::ClientId => Ok(Some(Value::String(client_id.to_string()))), + Self::ClientSecret => Ok(Some(Value::String(client_secret.to_string()))), + Self::Scopes => Ok(Some(Value::String(scopes.join(" ")))), + Self::ScopesList => Ok(Some(Value::Array( + scopes + .iter() + .map(|scope| Value::String(scope.clone())) + .collect(), + ))), + Self::RefreshToken => refresh_token + .map(|token| Value::String(token.to_string())) + .map(Some) + .ok_or_else(|| CliError::Auth("OAuth2 refresh token is missing".to_string())), + Self::Literal(value) => Ok(Some(value.clone())), + Self::Env { + name, + parse_json, + required, + } => { + let Some(value) = read_oauth_env(name, *required, "token request")? else { + return Ok(None); + }; + if *parse_json { + Ok(Some( + serde_json::from_str(&value).unwrap_or_else(|_| Value::String(value)), + )) + } else { + Ok(Some(Value::String(value))) + } + } + } + } + + pub(crate) fn required_env_var(&self) -> Option<&str> { + match self { + Self::Env { + name, + required: true, + .. + } => Some(name), + _ => None, + } + } +} + +#[derive(Debug, Clone)] +pub enum OAuth2RequestLocation { + Body(Vec), + Query { name: String, allow_multiple: bool }, +} + +#[derive(Debug, Clone)] +pub struct OAuth2RequestProperty { + pub(crate) location: OAuth2RequestLocation, + pub(crate) value: OAuth2RequestValue, +} + +impl OAuth2RequestProperty { + pub fn body(path: I, value: OAuth2RequestValue) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + location: OAuth2RequestLocation::Body(path.into_iter().map(Into::into).collect()), + value, + } + } + + pub fn query(name: impl Into, value: OAuth2RequestValue) -> Self { + Self { + location: OAuth2RequestLocation::Query { + name: name.into(), + allow_multiple: false, + }, + value, + } + } + + pub fn query_multiple(name: impl Into, value: OAuth2RequestValue) -> Self { + Self { + location: OAuth2RequestLocation::Query { + name: name.into(), + allow_multiple: true, + }, + value, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum OAuth2BodyEncoding { + None, + Json(String), + Form, +} + +#[derive(Debug, Clone)] +pub struct OAuth2Endpoint { + pub(crate) default_url: String, + pub(crate) path: String, + pub(crate) method: String, + pub(crate) use_base_url_override: bool, + pub(crate) body_encoding: OAuth2BodyEncoding, + pub(crate) request_properties: Vec, + pub(crate) access_token_path: Vec, + pub(crate) expires_in_path: Option>, + pub(crate) refresh_token_path: Option>, +} + +impl OAuth2Endpoint { + pub fn new(default_url: impl Into, path: impl Into) -> Self { + Self { + default_url: default_url.into(), + path: path.into(), + method: "POST".to_string(), + use_base_url_override: false, + body_encoding: OAuth2BodyEncoding::None, + request_properties: Vec::new(), + access_token_path: vec!["access_token".to_string()], + expires_in_path: None, + refresh_token_path: None, + } + } + + pub fn method(mut self, method: impl Into) -> Self { + self.method = method.into(); + self + } + + pub fn use_base_url_override(mut self) -> Self { + self.use_base_url_override = true; + self + } + + pub fn json_body(mut self, content_type: impl Into) -> Self { + self.body_encoding = OAuth2BodyEncoding::Json(content_type.into()); + self + } + + pub fn form_body(mut self) -> Self { + self.body_encoding = OAuth2BodyEncoding::Form; + self + } + + pub fn request_property(mut self, property: OAuth2RequestProperty) -> Self { + self.request_properties.push(property); + self + } + + pub fn access_token_path(mut self, path: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.access_token_path = path.into_iter().map(Into::into).collect(); + self + } + + pub fn expires_in_path(mut self, path: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.expires_in_path = Some(path.into_iter().map(Into::into).collect()); + self + } + + pub fn refresh_token_path(mut self, path: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.refresh_token_path = Some(path.into_iter().map(Into::into).collect()); + self + } + + pub(crate) fn resolve_url(&self, base_url_override: Option<&str>) -> String { + if self.use_base_url_override { + if let Some(base_url) = base_url_override { + return join_url(base_url, &self.path); + } + } + self.default_url.clone() + } + + pub(crate) fn required_env_vars(&self) -> impl Iterator { + self.request_properties + .iter() + .filter_map(|property| property.value.required_env_var()) + } +} + +fn join_url(base_url: &str, path: &str) -> String { + format!( + "{}/{}", + base_url.trim_end_matches('/'), + path.trim_start_matches('/') + ) +} diff --git a/src/auth/oauth_common.rs b/src/auth/oauth_common.rs new file mode 100644 index 0000000..080bcbe --- /dev/null +++ b/src/auth/oauth_common.rs @@ -0,0 +1,326 @@ +//! OAuth2 primitives shared by `OAuth2TokenProvider` (oauth2.rs) and the +//! login-flow providers (oauth_login.rs). +//! +//! Both providers parse OAuth2 token-endpoint responses, persist token +//! bundles, and resolve config paths the same way. Earlier each carried +//! its own copies of the helpers — the signatures had already diverged +//! (`parse_oauth_error_json -> Option` vs +//! `parse_oauth_error -> Option`, +//! `now_epoch_secs` vs `now_epoch`, timeouts present in one and not the +//! other). This module is the single source of truth. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Token endpoint response shapes +// --------------------------------------------------------------------------- + +/// Successful OAuth2 token-endpoint response (RFC 6749 §5.1, §6, §8). +#[derive(Deserialize, Debug)] +pub(crate) struct TokenSuccessBody { + pub access_token: String, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub expires_in: Option, +} + +/// Error envelope returned by an OAuth2 token endpoint (RFC 6749 §5.2). +#[derive(Deserialize, Debug)] +pub(crate) struct TokenErrorBody { + pub error: Option, + #[serde(rename = "error_description", default)] + pub error_description: Option, +} + +/// Parse an OAuth2 error envelope into its structured form. Returns `None` +/// if the body isn't a JSON object matching the OAuth2 error shape. +pub(crate) fn parse_oauth_error_body(body: &str) -> Option { + serde_json::from_str(body).ok() +} + +/// Format an OAuth2 error envelope as `": "`, falling +/// back to one or the other when only one field is present. Returns `None` +/// when the body doesn't parse or carries no field. +pub(crate) fn parse_oauth_error_message(body: &str) -> Option { + let err = parse_oauth_error_body(body)?; + match (err.error, err.error_description) { + (Some(e), Some(d)) => Some(format!("{e}: {d}")), + (Some(e), None) => Some(e), + (None, Some(d)) => Some(d), + (None, None) => None, + } +} + +/// Truncate a response body for inclusion in an error message, preserving +/// UTF-8 boundaries. +pub(crate) fn truncate_body(body: &str) -> String { + const MAX: usize = 512; + if body.chars().count() <= MAX { + body.to_string() + } else { + let s: String = body.chars().take(MAX).collect(); + format!("{s}…") + } +} + +/// Build the HTTP client used for token-endpoint requests across all +/// OAuth flows. Bounded timeouts so a hung endpoint surfaces an error +/// rather than freezing the CLI — 10s connect, 30s overall (enough for +/// slow providers like Microsoft identity, tight enough to detect a +/// misconfiguration before the user assumes a hang). +pub(crate) fn token_http_client() -> Result { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| CliError::Auth(format!("build OAuth HTTP client: {e}"))) +} + +/// Read an environment variable used by an OAuth2 token exchange. +/// +/// Whitespace-only values are treated as unset. When `required`, an absent +/// or empty variable is an error; otherwise it yields `Ok(None)`. `context` +/// describes the caller for the error message (e.g. `"client_id"`, +/// `"token request"`). +pub(crate) fn read_oauth_env( + name: &str, + required: bool, + context: &str, +) -> Result, CliError> { + let value = match std::env::var(name) { + Ok(value) => value, + Err(std::env::VarError::NotPresent) if !required => return Ok(None), + Err(_) => { + return Err(CliError::Auth(format!( + "Missing environment variable {name} (OAuth2 {context})" + ))); + } + }; + if value.trim().is_empty() { + if !required { + return Ok(None); + } + return Err(CliError::Auth(format!( + "Environment variable {name} (OAuth2 {context}) must be non-empty" + ))); + } + Ok(Some(value)) +} + +/// Current epoch seconds. +pub(crate) fn now_epoch() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Buffer subtracted from `expires_in` before computing `expires_at`, so +/// we refresh before the token actually expires. Matches the TS SDK's +/// `BUFFER_IN_MINUTES` constant. +pub(crate) const EXPIRY_BUFFER_SECS: u64 = 120; + +// --------------------------------------------------------------------------- +// TokenBundle — the JSON shape persisted in storage +// --------------------------------------------------------------------------- + +/// Cached OAuth2 access + refresh token state. +/// +/// Same shape regardless of where it's persisted (the legacy `TokenCache` +/// file map, the new `KeyringStore`). Both providers serialise this +/// directly; the storage layer just sees the resulting JSON string. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenBundle { + pub access_token: String, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub refresh_token: Option, + /// Epoch seconds when the access token expires. `None` = no expiry known. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub expires_at: Option, +} + +impl TokenBundle { + pub fn from_token_response( + access: &str, + refresh: Option<&str>, + expires_in: Option, + ) -> Self { + let expires_at = expires_in.map(|s| now_epoch() + s.saturating_sub(EXPIRY_BUFFER_SECS)); + Self { + access_token: access.to_string(), + refresh_token: refresh.map(str::to_string), + expires_at, + } + } + + pub fn is_expired(&self) -> bool { + match self.expires_at { + Some(t) => now_epoch() >= t, + None => false, + } + } + + pub fn to_keyring_value(&self) -> Result { + serde_json::to_string(self) + .map_err(|e| CliError::Auth(format!("serialise token bundle: {e}"))) + } + + /// Parse a keyring value into a bundle. Falls back to "treat as raw + /// bearer token" if JSON-decode fails — so `--with-token` paste + /// strings coexist with OAuth bundles under the same key. + pub fn parse_or_raw(value: &str) -> Self { + match serde_json::from_str::(value) { + Ok(b) => b, + Err(_) => Self { + access_token: value.to_string(), + refresh_token: None, + expires_at: None, + }, + } + } +} + +// --------------------------------------------------------------------------- +// Filesystem helpers +// --------------------------------------------------------------------------- + +/// Cross-platform home directory lookup: `$HOME` first (set on Unix and +/// honored on Windows under WSL/MSYS shells), then `%USERPROFILE%` as the +/// native Windows fallback. +pub(crate) fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) +} + +/// Platform-appropriate user config directory. +/// - macOS: `~/Library/Application Support` +/// - Windows: `%APPDATA%` (with `~/AppData/Roaming` fallback) +/// - Linux/other: `$XDG_CONFIG_HOME` (with `~/.config` fallback) +pub(crate) fn config_dir() -> Option { + let home = home_dir()?; + #[cfg(target_os = "macos")] + { + Some(home.join("Library").join("Application Support")) + } + #[cfg(target_os = "windows")] + { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .or(Some(home.join("AppData").join("Roaming"))) + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or(Some(home.join(".config"))) + } +} + +/// Write `data` to `path` atomically: sibling temp file → owner-only +/// permissions (0600 on Unix) → rename into place. +pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> Result<(), CliError> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data).map_err(|e| { + CliError::Auth(format!("Failed to write {}: {e}", tmp.display())) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + let _ = std::fs::set_permissions(&tmp, perms); + } + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + CliError::Auth(format!("Failed to rename {}: {e}", tmp.display())) + }) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_bundle_roundtrip() { + let b = TokenBundle::from_token_response("a", Some("r"), Some(3600)); + let s = b.to_keyring_value().unwrap(); + let parsed = TokenBundle::parse_or_raw(&s); + assert_eq!(parsed.access_token, "a"); + assert_eq!(parsed.refresh_token.as_deref(), Some("r")); + assert!(parsed.expires_at.is_some()); + } + + #[test] + fn token_bundle_raw_fallback() { + let b = TokenBundle::parse_or_raw("plain-token"); + assert_eq!(b.access_token, "plain-token"); + assert!(b.refresh_token.is_none()); + assert!(b.expires_at.is_none()); + assert!(!b.is_expired()); + } + + #[test] + fn token_bundle_expired_when_past_deadline() { + let mut b = TokenBundle::parse_or_raw("x"); + b.expires_at = Some(0); + assert!(b.is_expired()); + } + + #[test] + fn token_bundle_no_expires_at_never_expired() { + let b = TokenBundle::parse_or_raw("x"); + assert!(!b.is_expired()); + } + + #[test] + fn parse_oauth_error_message_prefers_error_and_description() { + let body = r#"{"error":"invalid_client","error_description":"bad secret"}"#; + assert_eq!( + parse_oauth_error_message(body).as_deref(), + Some("invalid_client: bad secret") + ); + } + + #[test] + fn parse_oauth_error_message_falls_back_to_description_only() { + let body = r#"{"error_description":"some detail"}"#; + assert_eq!(parse_oauth_error_message(body).as_deref(), Some("some detail")); + } + + #[test] + fn parse_oauth_error_message_none_on_non_json() { + assert!(parse_oauth_error_message("not-json").is_none()); + } + + #[test] + fn truncate_body_short_passes_through() { + assert_eq!(truncate_body("short"), "short"); + } + + #[test] + fn truncate_body_long_gets_ellipsis() { + let s = "x".repeat(600); + let t = truncate_body(&s); + assert!(t.len() < s.len()); + assert!(t.ends_with('…')); + } + + #[test] + fn truncate_body_multibyte_utf8_no_panic() { + let s = "é".repeat(600); + let t = truncate_body(&s); + assert!(t.chars().count() <= 513); + assert!(t.ends_with('…')); + } +} diff --git a/src/auth/oauth_login.rs b/src/auth/oauth_login.rs new file mode 100644 index 0000000..4664748 --- /dev/null +++ b/src/auth/oauth_login.rs @@ -0,0 +1,1906 @@ +//! OAuth login flows + the auth provider that resolves their tokens at request time. +//! +//! - [`DeviceCodeLoginFlow`] — RFC 8628. Runs on ` auth login`. +//! - [`PkceLoginFlow`] — authorization code + PKCE. Runs on ` auth login`. +//! (TB4 — see below.) +//! - [`OAuth2KeyringProvider`] — the request-time +//! [`AuthProvider`](crate::auth::AuthProvider) used by both flows. Reads +//! the JSON token bundle from the active keyring, refreshes via the +//! token URL when the access token has expired, applies the result as +//! `Authorization: Bearer <…>`. +//! +//! All three pieces share the JSON `TokenBundle` schema: +//! +//! ```json +//! { +//! "access_token": "…", +//! "refresh_token": "…", // optional — present iff the server returned one +//! "expires_at": 1715550000 // epoch seconds; optional iff the server didn't return expires_in +//! } +//! ``` +//! +//! The bundle is stored at keyring `(service=, account=)`, +//! same key shape as the universal `--with-token` paste (which stores a +//! plain string instead). The provider tries JSON-decode first, falls back +//! to treating the value as a raw bearer token — so paste-stored tokens +//! and OAuth-flow tokens coexist seamlessly. + +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use secrecy::{ExposeSecret, SecretString}; +use serde::Deserialize; + +use crate::auth::keyring_store::active_store; +use crate::auth::login::{LoginContext, LoginFlow}; +use crate::auth::oauth_common::{ + parse_oauth_error_body, token_http_client, truncate_body, TokenSuccessBody, +}; +// `TokenBundle` continues to be re-exported from this module for backward +// compatibility with `crate::auth::oauth_login::TokenBundle` import paths. +pub use crate::auth::oauth_common::TokenBundle; +use crate::auth::provider::{AuthProvider, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Device-code flow (RFC 8628) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct DeviceAuthBody { + device_code: String, + user_code: String, + verification_uri: String, + #[serde(default)] + verification_uri_complete: Option, + expires_in: u64, + #[serde(default = "default_interval")] + interval: u64, +} + +fn default_interval() -> u64 { + 5 +} + +/// Extra literal OAuth parameters (e.g. `audience`, `resource`) appended to an +/// authorization, token, device-authorization, or refresh request. Sourced from +/// the IR's `authorizationParameters` / `tokenParameters` / `refreshParameters` +/// maps, which are optional — an empty list is a no-op. +type ExtraParams = Vec<(String, String)>; + +/// Append `extra` params to a form, skipping any protocol-reserved keys the flow +/// controls itself, so user config can't clobber the handshake (RFC 6749). +fn extend_with_extra(form: &mut Vec<(String, String)>, extra: &ExtraParams, reserved: &[&str]) { + for (key, value) in extra { + if reserved.iter().any(|r| *r == key.as_str()) { + continue; + } + form.push((key.clone(), value.clone())); + } +} + +/// Device-code login flow. +/// +/// Generator-emitted main.rs calls this with values from the OpenAPI +/// `flows.deviceCode` block + `x-fern-cli-auth` extension (see ADR-0007). +#[derive(Debug, Clone)] +pub struct DeviceCodeLoginFlow { + scheme: String, + client_id: String, + device_authorization_url: String, + token_url: String, + scopes: Vec, + token_paste_url: Option, + device_authorization_params: ExtraParams, + token_params: ExtraParams, + refresh_params: ExtraParams, +} + +impl DeviceCodeLoginFlow { + pub fn new(scheme: impl Into) -> Self { + Self { + scheme: scheme.into(), + client_id: String::new(), + device_authorization_url: String::new(), + token_url: String::new(), + scopes: Vec::new(), + token_paste_url: None, + device_authorization_params: Vec::new(), + token_params: Vec::new(), + refresh_params: Vec::new(), + } + } + + pub fn client_id(mut self, v: impl Into) -> Self { + self.client_id = v.into(); + self + } + pub fn device_authorization_url(mut self, v: impl Into) -> Self { + self.device_authorization_url = v.into(); + self + } + pub fn token_url(mut self, v: impl Into) -> Self { + self.token_url = v.into(); + self + } + pub fn scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + pub fn token_paste_url(mut self, v: impl Into) -> Self { + self.token_paste_url = Some(v.into()); + self + } + /// Extra literal parameters included in the device authorization request (e.g. `audience`). + pub fn device_authorization_params(mut self, params: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.device_authorization_params = params.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + /// Extra literal parameters included in the device-code token exchange (polling) request. + pub fn token_params(mut self, params: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.token_params = params.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + /// Extra literal parameters included in the refresh token request. + pub fn refresh_params(mut self, params: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.refresh_params = params.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + + fn validate(&self) -> Result<(), CliError> { + if self.client_id.is_empty() { + return Err(CliError::Validation(format!( + "DeviceCodeLoginFlow `{}`: client_id is required", + self.scheme + ))); + } + if self.device_authorization_url.is_empty() { + return Err(CliError::Validation(format!( + "DeviceCodeLoginFlow `{}`: device_authorization_url is required", + self.scheme + ))); + } + if self.token_url.is_empty() { + return Err(CliError::Validation(format!( + "DeviceCodeLoginFlow `{}`: token_url is required", + self.scheme + ))); + } + Ok(()) + } +} + +impl LoginFlow for DeviceCodeLoginFlow { + fn flow_type(&self) -> &'static str { + "device-code" + } + fn scheme_name(&self) -> &str { + &self.scheme + } + fn token_paste_url(&self) -> Option<&str> { + self.token_paste_url.as_deref() + } + fn run(&self, ctx: &LoginContext) -> Result<(), CliError> { + self.validate()?; + let scopes = self.scopes.clone(); + let scope = if scopes.is_empty() { + None + } else { + Some(scopes.join(" ")) + }; + + // The flow runs synchronously from the user's perspective, but the + // HTTP calls are async — block_on inside the existing runtime. + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(run_device_code( + &ctx.cli_name, + &self.scheme, + &self.client_id, + &self.device_authorization_url, + &self.token_url, + scope.as_deref(), + ctx.no_browser, + &self.device_authorization_params, + &self.token_params, + )) + }) + } + fn build_auth_provider(&self, cli_name: &str) -> Option { + Some(Arc::new( + OAuth2KeyringProvider::new(&self.scheme, cli_name, &self.token_url, &self.client_id) + .with_refresh_params(self.refresh_params.clone()), + )) + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_device_code( + cli_name: &str, + scheme: &str, + client_id: &str, + device_auth_url: &str, + token_url: &str, + scope: Option<&str>, + no_browser: bool, + device_authorization_params: &ExtraParams, + token_params: &ExtraParams, +) -> Result<(), CliError> { + use std::io::Write; + + let http = token_http_client()?; + + // 1. Request device + user codes. + let mut device_form: Vec<(String, String)> = + vec![("client_id".to_string(), client_id.to_string())]; + if let Some(s) = scope { + device_form.push(("scope".to_string(), s.to_string())); + } + extend_with_extra(&mut device_form, device_authorization_params, &["client_id", "scope"]); + let resp = http + .post(device_auth_url) + .form(&device_form) + .send() + .await + .map_err(|e| CliError::Auth(format!("device auth request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CliError::Auth(format!("device auth response body: {e}")))?; + if !status.is_success() { + let detail = parse_oauth_error_body(&body) + .and_then(|e| e.error_description.or(e.error)) + .unwrap_or_else(|| truncate_body(&body)); + return Err(CliError::Auth(format!( + "device authorization endpoint returned HTTP {status}: {detail}" + ))); + } + let device: DeviceAuthBody = serde_json::from_str(&body) + .map_err(|e| CliError::Auth(format!("device auth response not JSON: {e}")))?; + + // 2. Show the user code + URL. + let verification_url = device + .verification_uri_complete + .clone() + .unwrap_or_else(|| device.verification_uri.clone()); + { + let mut err = std::io::stderr().lock(); + let _ = writeln!(err, "! First copy your one-time code: {}", device.user_code); + let _ = writeln!(err, " Then visit: {}", device.verification_uri); + if !no_browser { + let _ = writeln!(err, " Opening browser…"); + } else { + let _ = writeln!(err, " (browser not opened — use the URL above)"); + } + let _ = err.flush(); + } + if !no_browser { + let _ = webbrowser::open(&verification_url); + } + + // 3. Poll the token endpoint. + let mut token_form: Vec<(String, String)> = vec![ + ( + "grant_type".to_string(), + "urn:ietf:params:oauth:grant-type:device_code".to_string(), + ), + ("client_id".to_string(), client_id.to_string()), + ("device_code".to_string(), device.device_code.clone()), + ]; + extend_with_extra(&mut token_form, token_params, &["grant_type", "client_id", "device_code"]); + // Floor the poll interval at 1 second. RFC 8628 §3.5 mandates a + // minimum of 5s in production, but tests deliberately use interval=0 + // for speed. A 1s floor keeps tests fast while preventing any + // production server's `interval=0` from busy-looping the token + // endpoint. + let mut interval = device.interval.max(1); + let deadline = std::time::Instant::now() + Duration::from_secs(device.expires_in); + + loop { + if std::time::Instant::now() >= deadline { + return Err(CliError::Auth( + "Device code expired before authorization was granted. Run `auth login` again.".to_string(), + )); + } + + tokio::time::sleep(Duration::from_secs(interval)).await; + + let resp = http + .post(token_url) + .form(&token_form) + .send() + .await + .map_err(|e| CliError::Auth(format!("device token poll failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CliError::Auth(format!("device token response body: {e}")))?; + + if status.is_success() { + let ok: TokenSuccessBody = serde_json::from_str(&body).map_err(|e| { + CliError::Auth(format!("token response not JSON: {e}")) + })?; + let bundle = TokenBundle::from_token_response( + &ok.access_token, + ok.refresh_token.as_deref(), + ok.expires_in, + ); + active_store().set(cli_name, scheme, &bundle.to_keyring_value()?)?; + { + let mut err = std::io::stderr().lock(); + let _ = writeln!( + err, + "{}", + crate::auth::login::green(&format!( + "✓ Authenticated. Stored credential in {}.", + active_store().backend_label() + )) + ); + } + return Ok(()); + } + + // Distinguish polling-control errors (continue) from terminal errors (stop). + let parsed = parse_oauth_error_body(&body); + let code = parsed + .as_ref() + .and_then(|e| e.error.as_deref()) + .unwrap_or("") + .to_string(); + match code.as_str() { + "authorization_pending" => continue, + "slow_down" => { + interval = interval.saturating_add(5); + continue; + } + "access_denied" => { + return Err(CliError::Auth( + "Authorization was denied. Run `auth login` again to retry.".to_string(), + )); + } + "expired_token" => { + return Err(CliError::Auth( + "Device code expired. Run `auth login` again.".to_string(), + )); + } + other => { + let detail = parsed + .and_then(|e| e.error_description) + .unwrap_or_else(|| truncate_body(&body)); + return Err(CliError::Auth(format!( + "device token poll failed ({status}, code={other}): {detail}" + ))); + } + } + } +} + +// --------------------------------------------------------------------------- +// PKCE flow (authorization code + PKCE, RFC 7636) +// --------------------------------------------------------------------------- + +use base64::Engine; +use sha2::Digest; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +const CODE_VERIFIER_LEN: usize = 64; + +/// Generate a `code_verifier` per RFC 7636 §4.1 — 43-128 chars from the +/// unreserved set. 64 random base64url chars satisfies both length and +/// alphabet constraints. +fn generate_code_verifier() -> String { + use rand::Rng; + let mut bytes = [0u8; CODE_VERIFIER_LEN]; + rand::thread_rng().fill(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// SHA-256(code_verifier) base64url-no-pad — the `code_challenge` per +/// RFC 7636 §4.2. +fn code_challenge_s256(verifier: &str) -> String { + let hash = sha2::Sha256::digest(verifier.as_bytes()); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash) +} + +/// PKCE login flow. +#[derive(Debug, Clone)] +pub struct PkceLoginFlow { + scheme: String, + client_id: String, + authorization_url: String, + token_url: String, + scopes: Vec, + /// The primary loopback callback port. `None` = ephemeral (OS-assigned) port per RFC 8252 + /// §7.3; `Some(port)` pins an exact port the authorization server must have pre-registered. + /// At login time `run_pkce` resolves this to the actually-bound port. + redirect_port: Option, + /// Ordered fallback ports, tried after `redirect_port` when it's busy. Each must also be + /// pre-registered with the authorization server. Empty unless set via `redirect_ports`. + redirect_backup_ports: Vec, + /// Loopback host the callback listener binds and the redirect URI is built with. `None` + /// defaults to `127.0.0.1` (RFC 8252 §7.3). Set to `localhost` when the authorization server + /// registered a `localhost` redirect (must match exactly). Only loopback hosts are valid. + redirect_host: Option, + /// Callback path served by the listener and used in the redirect URI. `None` defaults to + /// `/callback`. Set to match a non-`/callback` registered redirect path. + redirect_path: Option, + token_paste_url: Option, + authorization_params: ExtraParams, + token_params: ExtraParams, + refresh_params: ExtraParams, +} + +impl PkceLoginFlow { + pub fn new(scheme: impl Into) -> Self { + Self { + scheme: scheme.into(), + client_id: String::new(), + authorization_url: String::new(), + token_url: String::new(), + scopes: Vec::new(), + redirect_port: None, + redirect_backup_ports: Vec::new(), + redirect_host: None, + redirect_path: None, + token_paste_url: None, + authorization_params: Vec::new(), + token_params: Vec::new(), + refresh_params: Vec::new(), + } + } + + pub fn client_id(mut self, v: impl Into) -> Self { + self.client_id = v.into(); + self + } + pub fn authorization_url(mut self, v: impl Into) -> Self { + self.authorization_url = v.into(); + self + } + pub fn token_url(mut self, v: impl Into) -> Self { + self.token_url = v.into(); + self + } + pub fn scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + /// Pin an exact loopback callback port. Omit this to use an ephemeral (OS-assigned) port. + pub fn redirect_port(mut self, port: u16) -> Self { + self.redirect_port = Some(port); + self + } + /// Pin an ordered set of loopback callback ports: the first is preferred, the rest are + /// fallbacks tried (in order) when an earlier one is busy. All must be pre-registered with the + /// authorization server. An empty list is a no-op (leaves the flow on an ephemeral port). + pub fn redirect_ports(mut self, ports: I) -> Self + where + I: IntoIterator, + { + let mut ports = ports.into_iter(); + if let Some(primary) = ports.next() { + self.redirect_port = Some(primary); + self.redirect_backup_ports = ports.collect(); + } + self + } + /// Set the loopback host (`127.0.0.1` or `localhost`) — must match the registered redirect. + pub fn redirect_host(mut self, host: impl Into) -> Self { + self.redirect_host = Some(host.into()); + self + } + /// Set the callback path (defaults to `/callback`) — must match the registered redirect. + pub fn redirect_path(mut self, path: impl Into) -> Self { + self.redirect_path = Some(path.into()); + self + } + /// The loopback host the listener binds and the redirect URI uses. Defaults to `127.0.0.1`. + fn redirect_host_str(&self) -> &str { + self.redirect_host.as_deref().unwrap_or("127.0.0.1") + } + /// The callback path. Defaults to `/callback`. + fn redirect_path_str(&self) -> &str { + self.redirect_path.as_deref().unwrap_or("/callback") + } + pub fn token_paste_url(mut self, v: impl Into) -> Self { + self.token_paste_url = Some(v.into()); + self + } + /// Extra literal parameters appended to the authorization request (e.g. `audience`). + pub fn authorization_params(mut self, params: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.authorization_params = params.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + /// Extra literal parameters included in the authorization-code token exchange. + pub fn token_params(mut self, params: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.token_params = params.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + /// Extra literal parameters included in the refresh token request. + pub fn refresh_params(mut self, params: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.refresh_params = params.into_iter().map(|(k, v)| (k.into(), v.into())).collect(); + self + } + + fn validate(&self) -> Result<(), CliError> { + if self.client_id.is_empty() { + return Err(CliError::Validation(format!( + "PkceLoginFlow `{}`: client_id is required", + self.scheme + ))); + } + if self.authorization_url.is_empty() { + return Err(CliError::Validation(format!( + "PkceLoginFlow `{}`: authorization_url is required", + self.scheme + ))); + } + if self.token_url.is_empty() { + return Err(CliError::Validation(format!( + "PkceLoginFlow `{}`: token_url is required", + self.scheme + ))); + } + Ok(()) + } + + /// The loopback callback URI. At login time `run_pkce` resolves `redirect_port` to the actual + /// bound port (ephemeral or pinned) before this is read, so the authorize request and the token + /// exchange always use the same port. The `unwrap_or(0)` is only reached by direct unit tests + /// that never bind a listener. + fn redirect_uri(&self) -> String { + format!( + "http://{}:{}{}", + self.redirect_host_str(), + self.redirect_port.unwrap_or(0), + self.redirect_path_str() + ) + } + + fn build_authorize_url(&self, state: &str, challenge: &str) -> String { + use form_urlencoded::Serializer; + let scopes = self.scopes.join(" "); + let mut pairs = Serializer::new(String::new()); + pairs + .append_pair("response_type", "code") + .append_pair("client_id", &self.client_id) + .append_pair("redirect_uri", &self.redirect_uri()) + .append_pair("state", state) + .append_pair("code_challenge", challenge) + .append_pair("code_challenge_method", "S256"); + if !scopes.is_empty() { + pairs.append_pair("scope", &scopes); + } + // Extra literal params (e.g. Auth0 `audience`), skipping protocol-reserved keys. + const RESERVED: &[&str] = &[ + "response_type", + "client_id", + "redirect_uri", + "state", + "code_challenge", + "code_challenge_method", + "scope", + ]; + for (key, value) in &self.authorization_params { + if RESERVED.contains(&key.as_str()) { + continue; + } + pairs.append_pair(key, value); + } + let query = pairs.finish(); + let sep = if self.authorization_url.contains('?') { + '&' + } else { + '?' + }; + format!("{}{}{}", self.authorization_url, sep, query) + } +} + +impl LoginFlow for PkceLoginFlow { + fn flow_type(&self) -> &'static str { + "pkce" + } + fn scheme_name(&self) -> &str { + &self.scheme + } + fn token_paste_url(&self) -> Option<&str> { + self.token_paste_url.as_deref() + } + fn run(&self, ctx: &LoginContext) -> Result<(), CliError> { + self.validate()?; + let flow = self.clone(); + let ctx = ctx.clone(); + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(run_pkce(flow, ctx)) + }) + } + fn build_auth_provider(&self, cli_name: &str) -> Option { + Some(Arc::new( + OAuth2KeyringProvider::new(&self.scheme, cli_name, &self.token_url, &self.client_id) + .with_refresh_params(self.refresh_params.clone()), + )) + } +} + +/// Bind a loopback TCP listener on the first available port in `candidate_ports` (tried in order). +/// Pass `[0]` for an ephemeral (OS-assigned) port. Returns the bound listener, or an error naming +/// every candidate when all are taken. +async fn bind_loopback_listener(host: &str, candidate_ports: &[u16]) -> Result { + let mut last_err: Option = None; + for &port in candidate_ports { + match TcpListener::bind((host, port)).await { + Ok(listener) => return Ok(listener), + Err(e) => last_err = Some(e.to_string()), + } + } + let ports = candidate_ports + .iter() + .map(u16::to_string) + .collect::>() + .join(", "); + Err(CliError::Auth(format!( + "Could not bind any {host} callback port [{ports}] — is another instance running, or did you forget to register these redirect URIs? ({})", + last_err.unwrap_or_default() + ))) +} + +async fn run_pkce(mut flow: PkceLoginFlow, ctx: LoginContext) -> Result<(), CliError> { + use std::io::Write; + + let verifier = generate_code_verifier(); + let challenge = code_challenge_s256(&verifier); + let state = generate_code_verifier(); // reuse generator; just needs entropy + + // Bind the loopback listener first. `redirect_port = None` → bind port 0 so the OS assigns a + // free ephemeral port (RFC 8252 §7.3). A pinned `redirect_port` (with optional backups) is + // tried in order and the first free one wins; all are pre-registered with the authorization + // server, so whichever binds still matches. Fails only if every candidate is taken. + let candidate_ports: Vec = match flow.redirect_port { + None => vec![0], + Some(primary) => { + let mut ports = Vec::with_capacity(1 + flow.redirect_backup_ports.len()); + ports.push(primary); + ports.extend(flow.redirect_backup_ports.iter().copied()); + ports + } + }; + let redirect_host = flow.redirect_host_str().to_string(); + let redirect_path = flow.redirect_path_str().to_string(); + let listener = bind_loopback_listener(&redirect_host, &candidate_ports).await?; + // Resolve the actually-bound port (the ephemeral one the OS chose, or the pinned one) and use + // it everywhere so the authorize request and token exchange carry the same redirect_uri. + let actual_port = listener + .local_addr() + .map_err(|e| CliError::Auth(format!("could not resolve loopback callback port: {e}")))? + .port(); + flow.redirect_port = Some(actual_port); + + let url = flow.build_authorize_url(&state, &challenge); + let listening_uri = flow.redirect_uri(); + // Take the stderr lock, write, drop — before any .await — to keep + // the future Send across awaits. + { + let mut err = std::io::stderr().lock(); + let _ = writeln!(err, "Opening browser to authenticate…"); + let _ = writeln!(err, " URL: {url}"); + let _ = writeln!(err, " Listening on {listening_uri}"); + let _ = err.flush(); + } + if !ctx.no_browser { + let _ = webbrowser::open(&url); + } + + // Wait for the browser to hit /callback with code+state. + let (code, received_state) = match accept_callback(&listener, &redirect_path).await { + Ok(v) => v, + Err(e) => return Err(e), + }; + + if received_state != state { + return Err(CliError::Auth(format!( + "OAuth state mismatch (expected `{state}`, got `{received_state}`) — possible CSRF; aborting" + ))); + } + + // Exchange the code. + let http = token_http_client()?; + let redirect_uri = flow.redirect_uri(); + let mut form: Vec<(String, String)> = vec![ + ("grant_type".to_string(), "authorization_code".to_string()), + ("code".to_string(), code.clone()), + ("code_verifier".to_string(), verifier.clone()), + ("client_id".to_string(), flow.client_id.clone()), + ("redirect_uri".to_string(), redirect_uri), + ]; + extend_with_extra( + &mut form, + &flow.token_params, + &["grant_type", "code", "code_verifier", "client_id", "redirect_uri"], + ); + let resp = http + .post(&flow.token_url) + .form(&form) + .send() + .await + .map_err(|e| CliError::Auth(format!("PKCE token exchange failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CliError::Auth(format!("token response body: {e}")))?; + if !status.is_success() { + let detail = parse_oauth_error_body(&body) + .and_then(|e| e.error_description.or(e.error)) + .unwrap_or_else(|| truncate_body(&body)); + return Err(CliError::Auth(format!( + "PKCE token exchange failed (HTTP {status}): {detail}" + ))); + } + let ok: TokenSuccessBody = serde_json::from_str(&body) + .map_err(|e| CliError::Auth(format!("token response not JSON: {e}")))?; + let bundle = TokenBundle::from_token_response( + &ok.access_token, + ok.refresh_token.as_deref(), + ok.expires_in, + ); + active_store().set(&ctx.cli_name, &flow.scheme, &bundle.to_keyring_value()?)?; + + { + let mut err = std::io::stderr().lock(); + let _ = writeln!( + err, + "{}", + crate::auth::login::green(&format!( + "✓ Authenticated. Stored credential in {}.", + active_store().backend_label() + )) + ); + } + Ok(()) +} + +const CALLBACK_RESPONSE_BODY: &str = "\ +Authenticated\ +\ +

You can close this tab.

\ +

The CLI received your authorization code.

\ +"; + +/// Accept one HTTP request on the loopback listener, parse `?code=…&state=…` +/// from the request line, send a small HTML response, return `(code, state)`. +/// Cap on how long the PKCE listener waits for the browser callback +/// before bailing. Five minutes matches typical OAuth authorization-code +/// lifetimes — if the user abandoned the browser tab or got distracted, +/// surfacing a clear timeout beats hanging silently. +const PKCE_CALLBACK_TIMEOUT: Duration = Duration::from_secs(300); + +async fn accept_callback(listener: &TcpListener, expected_path: &str) -> Result<(String, String), CliError> { + accept_callback_with_timeout(listener, expected_path, PKCE_CALLBACK_TIMEOUT).await +} + +async fn accept_callback_with_timeout( + listener: &TcpListener, + expected_path: &str, + timeout: Duration, +) -> Result<(String, String), CliError> { + match tokio::time::timeout(timeout, accept_callback_inner(listener, expected_path)).await { + Ok(r) => r, + Err(_) => Err(CliError::Auth(format!( + "Timed out waiting for the OAuth callback after {}s. \ + Run `auth login` again — if your browser didn't open, pass `--no-browser` \ + and visit the printed URL manually.", + timeout.as_secs() + ))), + } +} + +async fn accept_callback_inner(listener: &TcpListener, expected_path: &str) -> Result<(String, String), CliError> { + // Single-shot accept. If the browser hits us with a noisy preflight + // (favicon, etc.) we skip and accept the next; cap at 8 attempts. + for _ in 0..8 { + let (mut socket, _) = listener + .accept() + .await + .map_err(|e| CliError::Auth(format!("accept on loopback failed: {e}")))?; + + let mut buf = [0u8; 8192]; + let n = socket + .read(&mut buf) + .await + .map_err(|e| CliError::Auth(format!("read from loopback failed: {e}")))?; + if n == 0 { + continue; + } + let req = String::from_utf8_lossy(&buf[..n]); + let path = match parse_request_path(&req) { + Some(p) => p, + None => continue, + }; + // Match the configured callback path exactly (query string stripped). The listener must + // serve whatever path the redirect URI advertised — defaulting to `/callback`, but honoring + // a custom registered path — otherwise the browser callback 404s and login hangs. Anything + // else (favicon.ico, /.well-known, stray probes) is skipped. + let path_only = path.split('?').next().unwrap_or(path); + if path_only != expected_path { + let _ = write_response(&mut socket, 404, "not found").await; + continue; + } + + // Parse query. + let qs = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + let mut code = None; + let mut state = None; + let mut error_param = None; + for (k, v) in form_urlencoded::parse(qs.as_bytes()) { + match k.as_ref() { + "code" => code = Some(v.into_owned()), + "state" => state = Some(v.into_owned()), + "error" => error_param = Some(v.into_owned()), + _ => {} + } + } + + if let Some(e) = error_param { + let _ = write_response(&mut socket, 400, "authorization failed").await; + return Err(CliError::Auth(format!( + "Authorization server returned error: {e}" + ))); + } + + let (Some(code), Some(state)) = (code, state) else { + let _ = write_response(&mut socket, 400, "missing code or state").await; + return Err(CliError::Auth( + "callback missing `code` or `state` query parameter".to_string(), + )); + }; + + let _ = write_response_html(&mut socket, 200, CALLBACK_RESPONSE_BODY).await; + return Ok((code, state)); + } + Err(CliError::Auth( + "Too many invalid requests on the loopback listener; giving up".to_string(), + )) +} + +fn parse_request_path(req: &str) -> Option<&str> { + // Request line: "GET /callback?... HTTP/1.1\r\n" + let line = req.split("\r\n").next()?; + let mut parts = line.split_whitespace(); + let _method = parts.next()?; + parts.next() // path +} + +async fn write_response(socket: &mut tokio::net::TcpStream, status: u16, msg: &str) -> std::io::Result<()> { + let body = msg; + let resp = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + status_phrase(status), + body.len() + ); + socket.write_all(resp.as_bytes()).await?; + socket.flush().await +} + +async fn write_response_html(socket: &mut tokio::net::TcpStream, status: u16, body: &str) -> std::io::Result<()> { + let resp = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + status_phrase(status), + body.len() + ); + socket.write_all(resp.as_bytes()).await?; + socket.flush().await +} + +fn status_phrase(s: u16) -> &'static str { + match s { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + _ => "Status", + } +} + +// --------------------------------------------------------------------------- +// OAuth2KeyringProvider — request-time provider used by both flows +// --------------------------------------------------------------------------- + +/// Reads the access token from the active keyring, refreshes it when +/// expired via `token_url`, and applies `Authorization: Bearer <…>`. +/// +/// Memoises the resolved token per process via [`OnceLock`] so repeated +/// `apply()` calls in the same invocation never re-hit the keyring or +/// the network. +pub struct OAuth2KeyringProvider { + scheme_name: String, + cli_name: String, + token_url: String, + client_id: String, + refresh_params: ExtraParams, + cached: OnceLock>, +} + +impl std::fmt::Debug for OAuth2KeyringProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2KeyringProvider") + .field("scheme_name", &self.scheme_name) + .field("cli_name", &self.cli_name) + .field("token_url", &self.token_url) + .finish() + } +} + +impl OAuth2KeyringProvider { + pub fn new( + scheme_name: &str, + cli_name: &str, + token_url: &str, + client_id: &str, + ) -> Self { + Self { + scheme_name: scheme_name.to_string(), + cli_name: cli_name.to_string(), + token_url: token_url.to_string(), + client_id: client_id.to_string(), + refresh_params: Vec::new(), + cached: OnceLock::new(), + } + } + + /// Attach extra literal parameters (e.g. `audience`) to the refresh-token request. + /// Defaults to none, so existing callers are unaffected. + pub fn with_refresh_params(mut self, params: ExtraParams) -> Self { + self.refresh_params = params; + self + } + + fn resolve(&self) -> Result { + let result = self.cached.get_or_init(|| { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(self.resolve_async()) + .map(SecretString::from) + .map_err(|e| e.to_string()) + }) + }); + match result { + Ok(s) => Ok(s.clone()), + Err(m) => Err(CliError::Auth(m.clone())), + } + } + + async fn resolve_async(&self) -> Result { + let store = active_store(); + let raw = store.get(&self.cli_name, &self.scheme_name)?.ok_or_else(|| { + CliError::Auth(format!( + "Not logged in. Run `{} auth login` to authenticate.", + self.cli_name + )) + })?; + + let bundle = TokenBundle::parse_or_raw(&raw); + + if !bundle.is_expired() { + return Ok(bundle.access_token); + } + + let Some(refresh) = bundle.refresh_token.as_deref() else { + return Err(CliError::Auth(format!( + "Your session has expired and no refresh token is cached. Run `{} auth login` again.", + self.cli_name + ))); + }; + + // Refresh via token_url. RFC 6749 §6. + let http = token_http_client()?; + let mut form: Vec<(String, String)> = vec![ + ("grant_type".to_string(), "refresh_token".to_string()), + ("client_id".to_string(), self.client_id.clone()), + ("refresh_token".to_string(), refresh.to_string()), + ]; + extend_with_extra(&mut form, &self.refresh_params, &["grant_type", "client_id", "refresh_token"]); + let resp = http + .post(&self.token_url) + .form(&form) + .send() + .await + .map_err(|e| CliError::Auth(format!("refresh token request failed: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| CliError::Auth(format!("refresh token response body: {e}")))?; + if !status.is_success() { + // ADR-0008 § refresh-fails: wipe the keyring entry and tell + // the user to log in again. + let _ = store.delete(&self.cli_name, &self.scheme_name); + let detail = parse_oauth_error_body(&body) + .and_then(|e| e.error_description.or(e.error)) + .unwrap_or_else(|| truncate_body(&body)); + return Err(CliError::Auth(format!( + "Your session has expired ({detail}). Run `{} auth login` again.", + self.cli_name + ))); + } + let ok: TokenSuccessBody = serde_json::from_str(&body).map_err(|e| { + CliError::Auth(format!("refresh response not JSON: {e}")) + })?; + let new_bundle = TokenBundle::from_token_response( + &ok.access_token, + ok.refresh_token.as_deref().or(Some(refresh)), + ok.expires_in, + ); + store.set(&self.cli_name, &self.scheme_name, &new_bundle.to_keyring_value()?)?; + Ok(new_bundle.access_token) + } +} + +impl AuthProvider for OAuth2KeyringProvider { + fn name(&self) -> &str { + &self.scheme_name + } + + fn has_credentials(&self) -> bool { + active_store() + .get(&self.cli_name, &self.scheme_name) + .ok() + .flatten() + .map(|v| !v.is_empty()) + .unwrap_or(false) + } + + fn credential_hints(&self) -> Vec { + vec![format!( + "keyring entry {}:{} (populated by `{} auth login`)", + self.cli_name, self.scheme_name, self.cli_name + )] + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let token = self.resolve()?; + let mut value = String::with_capacity(7 + token.expose_secret().len()); + value.push_str("Bearer "); + value.push_str(token.expose_secret()); + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| CliError::Auth(format!("invalid bearer token: {e}")))?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::keyring_store::{set_active_store, KeyringStore, MockKeyringStore}; + use serial_test::serial; + use std::sync::atomic::{AtomicU32, Ordering}; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // `TokenBundle` roundtrip / raw-fallback / expiry tests live in + // `oauth_common::tests` — the canonical home for that type. + + #[test] + fn device_code_validates_required_fields() { + let flow = DeviceCodeLoginFlow::new("OAuth2"); + let err = flow.validate().unwrap_err(); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn device_code_flow_type_and_scheme() { + let f = DeviceCodeLoginFlow::new("OAuth2") + .client_id("x") + .device_authorization_url("https://d") + .token_url("https://t"); + assert_eq!(f.flow_type(), "device-code"); + assert_eq!(f.scheme_name(), "OAuth2"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn device_code_polling_succeeds_on_third_try() { + let server = MockServer::start().await; + let mock_store = Arc::new(MockKeyringStore::new()); + set_active_store(mock_store.clone()); + + // Device-authorization endpoint returns short interval to keep the test fast. + Mock::given(method("POST")) + .and(path("/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "device_code": "dev-code-xyz", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/device", + "expires_in": 600, + "interval": 0, + }))) + .expect(1) + .mount(&server) + .await; + + // Token endpoint: pending, pending, success. + let counter = Arc::new(AtomicU32::new(0)); + let c = counter.clone(); + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(move |_req: &wiremock::Request| { + let n = c.fetch_add(1, Ordering::SeqCst); + if n < 2 { + ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "authorization_pending" + })) + } else { + ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "acc-123", + "refresh_token": "ref-xyz", + "expires_in": 3600 + })) + } + }) + .expect(3) + .mount(&server) + .await; + + let flow = DeviceCodeLoginFlow::new("OAuth2") + .client_id("cli-id") + .device_authorization_url(format!("{}/device", server.uri())) + .token_url(format!("{}/token", server.uri())); + + let ctx = LoginContext { + cli_name: "my-cli".to_string(), + no_browser: true, + }; + flow.run(&ctx).expect("device-code flow should succeed"); + + let stored = mock_store.get("my-cli", "OAuth2").unwrap().unwrap(); + let bundle: TokenBundle = serde_json::from_str(&stored).unwrap(); + assert_eq!(bundle.access_token, "acc-123"); + assert_eq!(bundle.refresh_token.as_deref(), Some("ref-xyz")); + assert!(bundle.expires_at.is_some()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn device_code_handles_slow_down_increases_interval() { + let server = MockServer::start().await; + set_active_store(Arc::new(MockKeyringStore::new())); + + Mock::given(method("POST")) + .and(path("/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "device_code": "dc", + "user_code": "X", + "verification_uri": "https://e", + "expires_in": 600, + "interval": 0, + }))) + .mount(&server) + .await; + + let counter = Arc::new(AtomicU32::new(0)); + let c = counter.clone(); + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(move |_req: &wiremock::Request| { + let n = c.fetch_add(1, Ordering::SeqCst); + match n { + 0 => ResponseTemplate::new(400) + .set_body_json(serde_json::json!({ "error": "slow_down" })), + _ => ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "ok", "expires_in": 60 + })), + } + }) + .mount(&server) + .await; + + let flow = DeviceCodeLoginFlow::new("OAuth2") + .client_id("c") + .device_authorization_url(format!("{}/device", server.uri())) + .token_url(format!("{}/token", server.uri())); + flow.run(&LoginContext { + cli_name: "my-cli".to_string(), + no_browser: true, + }) + .unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn device_code_fails_on_access_denied() { + let server = MockServer::start().await; + set_active_store(Arc::new(MockKeyringStore::new())); + + Mock::given(method("POST")) + .and(path("/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "device_code": "dc", + "user_code": "X", + "verification_uri": "https://e", + "expires_in": 600, + "interval": 0, + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "access_denied" + }))) + .mount(&server) + .await; + + let flow = DeviceCodeLoginFlow::new("OAuth2") + .client_id("c") + .device_authorization_url(format!("{}/device", server.uri())) + .token_url(format!("{}/token", server.uri())); + let err = flow + .run(&LoginContext { + cli_name: "my-cli".to_string(), + no_browser: true, + }) + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.to_lowercase().contains("denied")); + } + + // ---------- OAuth2KeyringProvider ---------- + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_uses_cached_unexpired_token() { + let mock = Arc::new(MockKeyringStore::new()); + let bundle = TokenBundle::from_token_response("cached-acc", Some("r"), Some(3600)); + mock.set("my-cli", "OAuth2", &bundle.to_keyring_value().unwrap()) + .unwrap(); + set_active_store(mock); + + let p = OAuth2KeyringProvider::new("OAuth2", "my-cli", "https://unused", "client"); + let client = reqwest::Client::new(); + let req = client.get("https://example.com"); + let r = p + .apply(req, &EndpointAuthMetadata::unspecified()) + .unwrap() + .build() + .unwrap(); + let auth = r.headers().get("authorization").unwrap().to_str().unwrap(); + assert_eq!(auth, "Bearer cached-acc"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_refreshes_expired_token() { + let server = MockServer::start().await; + let mock = Arc::new(MockKeyringStore::new()); + let mut expired = TokenBundle::from_token_response("old", Some("ref-1"), Some(3600)); + expired.expires_at = Some(0); // forcibly expired + mock.set("my-cli", "OAuth2", &expired.to_keyring_value().unwrap()).unwrap(); + set_active_store(mock.clone()); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "new-acc", + "refresh_token": "ref-2", + "expires_in": 3600 + }))) + .expect(1) + .mount(&server) + .await; + + let p = OAuth2KeyringProvider::new( + "OAuth2", + "my-cli", + &format!("{}/token", server.uri()), + "client", + ); + let r = p + .apply(reqwest::Client::new().get("https://example.com"), &EndpointAuthMetadata::unspecified()) + .unwrap() + .build() + .unwrap(); + let auth = r.headers().get("authorization").unwrap().to_str().unwrap(); + assert_eq!(auth, "Bearer new-acc"); + + // New tokens persisted. + let stored: TokenBundle = serde_json::from_str(&mock.get("my-cli", "OAuth2").unwrap().unwrap()).unwrap(); + assert_eq!(stored.access_token, "new-acc"); + assert_eq!(stored.refresh_token.as_deref(), Some("ref-2")); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_wipes_keyring_when_refresh_fails() { + let server = MockServer::start().await; + let mock = Arc::new(MockKeyringStore::new()); + let mut expired = TokenBundle::from_token_response("old", Some("stale"), Some(3600)); + expired.expires_at = Some(0); + mock.set("my-cli", "OAuth2", &expired.to_keyring_value().unwrap()).unwrap(); + set_active_store(mock.clone()); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": "invalid_grant", + "error_description": "refresh token revoked" + }))) + .mount(&server) + .await; + + let p = OAuth2KeyringProvider::new( + "OAuth2", + "my-cli", + &format!("{}/token", server.uri()), + "client", + ); + let err = p + .apply(reqwest::Client::new().get("https://example.com"), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("session has expired")); + assert!(msg.contains("auth login")); + + // Keyring entry was wiped on the failed refresh — user has to log in again. + assert!(mock.get("my-cli", "OAuth2").unwrap().is_none()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_has_credentials_returns_true_when_keyring_populated() { + let mock = Arc::new(MockKeyringStore::new()); + mock.set("my-cli", "OAuth2", "anything").unwrap(); + set_active_store(mock); + let p = OAuth2KeyringProvider::new("OAuth2", "my-cli", "https://x", "c"); + assert!(p.has_credentials()); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_has_credentials_false_when_keyring_empty() { + set_active_store(Arc::new(MockKeyringStore::new())); + let p = OAuth2KeyringProvider::new("OAuth2", "my-cli", "https://x", "c"); + assert!(!p.has_credentials()); + } + + // ---------- Login-flow → request-time provider wiring ---------- + // + // These verify the two behaviors ElevenLabs asked for, tied to the *new* public-client + // login flows: the flow's `build_auth_provider` must produce a provider that + // 1. injects `Authorization: Bearer ` on requests, and + // 2. automatically refreshes an expired token against the flow's configured `token_url`. + // `CliApp::login_flow` registers exactly this provider, so this is the request-time path a + // generated CLI runs after `auth login`. + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn pkce_login_flow_provider_injects_bearer() { + let mock = Arc::new(MockKeyringStore::new()); + let bundle = TokenBundle::from_token_response("pkce-acc", Some("r"), Some(3600)); + mock.set("my-cli", "OAuth2", &bundle.to_keyring_value().unwrap()) + .unwrap(); + set_active_store(mock); + + let flow = PkceLoginFlow::new("OAuth2") + .client_id("public-client") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token"); + let provider = flow + .build_auth_provider("my-cli") + .expect("PKCE flow must register a request-time auth provider"); + + let req = provider + .apply( + reqwest::Client::new().get("https://example.com"), + &EndpointAuthMetadata::unspecified(), + ) + .unwrap() + .build() + .unwrap(); + let auth = req.headers().get("authorization").unwrap().to_str().unwrap(); + assert_eq!(auth, "Bearer pkce-acc"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn device_code_login_flow_provider_refreshes_via_token_url() { + let server = MockServer::start().await; + let mock = Arc::new(MockKeyringStore::new()); + let mut expired = TokenBundle::from_token_response("old", Some("ref-1"), Some(3600)); + expired.expires_at = Some(0); // forcibly expired + mock.set("my-cli", "OAuth2", &expired.to_keyring_value().unwrap()) + .unwrap(); + set_active_store(mock.clone()); + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "device-refreshed-acc", + "refresh_token": "ref-2", + "expires_in": 3600 + }))) + .expect(1) + .mount(&server) + .await; + + let flow = DeviceCodeLoginFlow::new("OAuth2") + .client_id("public-client") + .device_authorization_url("https://auth.example.com/device/code") + .token_url(&format!("{}/token", server.uri())); + let provider = flow + .build_auth_provider("my-cli") + .expect("device-code flow must register a request-time auth provider"); + + let req = provider + .apply( + reqwest::Client::new().get("https://example.com"), + &EndpointAuthMetadata::unspecified(), + ) + .unwrap() + .build() + .unwrap(); + let auth = req.headers().get("authorization").unwrap().to_str().unwrap(); + assert_eq!(auth, "Bearer device-refreshed-acc"); + + // The refreshed tokens were persisted for the next invocation. + let stored: TokenBundle = + serde_json::from_str(&mock.get("my-cli", "OAuth2").unwrap().unwrap()).unwrap(); + assert_eq!(stored.access_token, "device-refreshed-acc"); + assert_eq!(stored.refresh_token.as_deref(), Some("ref-2")); + } + + // ---------- Extra params (audience) passthrough ---------- + + #[test] + fn pkce_authorize_url_appends_extra_authorization_params() { + let f = PkceLoginFlow::new("OAuth2") + .client_id("id") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token") + .authorization_params([("audience", "https://api.acme.io")]); + let url = f.build_authorize_url("state123", "challenge123"); + assert!( + url.contains("audience=https%3A%2F%2Fapi.acme.io"), + "audience missing from authorize URL: {url}" + ); + } + + #[test] + fn pkce_authorize_url_ignores_reserved_param_override() { + // A user must not be able to clobber protocol-reserved keys via extra params. + let f = PkceLoginFlow::new("OAuth2") + .client_id("real-id") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token") + .authorization_params([("client_id", "attacker"), ("audience", "https://api.acme.io")]); + let url = f.build_authorize_url("s", "c"); + assert!(url.contains("client_id=real-id"), "reserved client_id was overridden: {url}"); + assert!(!url.contains("client_id=attacker"), "attacker client_id leaked: {url}"); + assert!(url.contains("audience=https%3A%2F%2Fapi.acme.io")); + } + + #[test] + fn extend_with_extra_appends_and_skips_reserved() { + // Shared helper used to build the token / device-authorization / refresh request bodies. + let extra: ExtraParams = vec![ + ("audience".to_string(), "https://api.acme.io".to_string()), + ("grant_type".to_string(), "attacker".to_string()), // reserved — must be dropped + ]; + let mut form: Vec<(String, String)> = vec![("grant_type".to_string(), "refresh_token".to_string())]; + extend_with_extra(&mut form, &extra, &["grant_type", "client_id", "refresh_token"]); + assert!(form.contains(&("audience".to_string(), "https://api.acme.io".to_string()))); + // The reserved `grant_type` was not clobbered or duplicated. + assert_eq!(form.iter().filter(|(k, _)| k == "grant_type").count(), 1); + assert!(form.iter().all(|(_, v)| v != "attacker")); + } + + // ---------- PKCE ---------- + + #[test] + fn code_verifier_is_url_safe_and_long_enough() { + let v = generate_code_verifier(); + assert!(v.len() >= 43 && v.len() <= 128, "verifier len {}", v.len()); + // URL-safe alphabet: A-Z a-z 0-9 - _ (no padding). + assert!(v.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')); + } + + #[test] + fn code_challenge_s256_matches_rfc_example() { + // RFC 7636 Appendix B example: + // code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // code_challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + let v = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + code_challenge_s256(v), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn pkce_authorize_url_includes_all_required_params() { + let f = PkceLoginFlow::new("OAuth2") + .client_id("my-id") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token") + .scopes(["read", "write"]) + .redirect_port(4711); + let url = f.build_authorize_url("state-abc", "challenge-xyz"); + assert!(url.contains("response_type=code")); + assert!(url.contains("client_id=my-id")); + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A4711%2Fcallback")); + assert!(url.contains("state=state-abc")); + assert!(url.contains("code_challenge=challenge-xyz")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("scope=read+write")); + } + + #[test] + fn pkce_pinned_redirect_port_is_honored() { + let flow = PkceLoginFlow::new("OAuth2") + .client_id("id") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token") + .redirect_port(8484); + assert_eq!(flow.redirect_port, Some(8484)); + assert_eq!(flow.redirect_uri(), "http://127.0.0.1:8484/callback"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn bind_loopback_listener_uses_first_free_port() { + // Occupy the first candidate; the loop must fall through to the next free one. + let occupied = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let taken = occupied.local_addr().unwrap().port(); + let free = { + let l = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + l.local_addr().unwrap().port() // freed when `l` drops at end of block + }; + + let listener = bind_loopback_listener("127.0.0.1", &[taken, free]).await.unwrap(); + assert_eq!( + listener.local_addr().unwrap().port(), + free, + "should skip the occupied port and bind the next free one" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn bind_loopback_listener_errors_when_all_taken() { + // Hold both candidate ports for the duration of the call. + let a = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let b = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let pa = a.local_addr().unwrap().port(); + let pb = b.local_addr().unwrap().port(); + + let err = bind_loopback_listener("127.0.0.1", &[pa, pb]).await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains(&pa.to_string()) && msg.contains(&pb.to_string()), "error should name all ports: {msg}"); + } + + #[test] + fn pkce_redirect_uri_honors_configured_host_and_path() { + // localhost + custom path must flow verbatim into the redirect URI (exact-match with the + // authorization server's registration); default is 127.0.0.1 + /callback. + let localhost = PkceLoginFlow::new("OAuth2") + .client_id("id") + .authorization_url("https://a") + .token_url("https://t") + .redirect_host("localhost") + .redirect_path("/oauth/callback") + .redirect_port(8484); + assert_eq!(localhost.redirect_uri(), "http://localhost:8484/oauth/callback"); + + let default = PkceLoginFlow::new("OAuth2").redirect_port(8484); + assert_eq!(default.redirect_uri(), "http://127.0.0.1:8484/callback"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn bind_loopback_listener_binds_localhost() { + // localhost must be a bindable loopback host (resolves to 127.0.0.1 or ::1 on the same box). + let listener = bind_loopback_listener("localhost", &[0]).await.unwrap(); + assert_ne!(listener.local_addr().unwrap().port(), 0); + } + + #[test] + fn pkce_redirect_ports_sets_primary_and_backups() { + let flow = PkceLoginFlow::new("OAuth2") + .client_id("id") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token") + .redirect_ports([8484, 8483, 8482]); + assert_eq!(flow.redirect_port, Some(8484)); + assert_eq!(flow.redirect_backup_ports, vec![8483, 8482]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_ephemeral_port_binds_nonzero_and_flows_into_redirect_uri() { + // No redirect_port pinned → ephemeral. Mirrors run_pkce's bind + resolve logic and asserts + // the OS-assigned port flows consistently into both redirect_uri and the authorize URL. + let mut flow = PkceLoginFlow::new("OAuth2") + .client_id("id") + .authorization_url("https://auth.example.com/authorize") + .token_url("https://auth.example.com/token"); + assert!(flow.redirect_port.is_none(), "default must be ephemeral"); + + let listener = TcpListener::bind(("127.0.0.1", flow.redirect_port.unwrap_or(0))) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + assert_ne!(port, 0, "OS should assign a nonzero ephemeral port"); + flow.redirect_port = Some(port); + + assert_eq!(flow.redirect_uri(), format!("http://127.0.0.1:{port}/callback")); + let url = flow.build_authorize_url("s", "c"); + assert!( + url.contains(&format!("127.0.0.1%3A{port}%2Fcallback")), + "authorize URL must carry the bound ephemeral port: {url}" + ); + } + + #[test] + fn pkce_authorize_url_appends_with_ampersand_when_query_present() { + let f = PkceLoginFlow::new("OAuth2") + .client_id("x") + .authorization_url("https://auth.example.com/authorize?prompt=login") + .token_url("https://t"); + let url = f.build_authorize_url("s", "c"); + assert!(url.contains("?prompt=login&response_type=code")); + } + + #[test] + fn pkce_validates_required_fields() { + let f = PkceLoginFlow::new("OAuth2"); + assert!(matches!(f.validate(), Err(CliError::Validation(_)))); + } + + #[test] + fn pkce_flow_type_and_scheme() { + let f = PkceLoginFlow::new("OAuth2") + .client_id("c") + .authorization_url("https://a") + .token_url("https://t"); + assert_eq!(f.flow_type(), "pkce"); + assert_eq!(f.scheme_name(), "OAuth2"); + } + + fn pick_free_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let p = l.local_addr().unwrap().port(); + drop(l); + p + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_loopback_times_out_when_no_callback_arrives() { + // When the browser never hits /callback (user closed tab, etc.), + // accept_callback() must return a clear timeout error rather than + // hanging forever. Drive accept_callback_with_timeout directly + // with a 100ms cap so the test runs at wall-clock speed instead + // of waiting the production 5-minute deadline. + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let err = accept_callback_with_timeout(&listener, "/callback", Duration::from_millis(100)) + .await + .expect_err("expected timeout when no browser callback arrives"); + let msg = format!("{err}"); + assert!( + msg.contains("Timed out") && msg.contains("auth login"), + "expected timeout error message, got: {msg}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_loopback_handshake_returns_code_and_state() { + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + + // Spawn the accept task. + let acceptor = tokio::spawn(async move { accept_callback(&listener, "/callback").await }); + + // Act as the browser. + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = reqwest::Client::new() + .get(format!( + "http://127.0.0.1:{port}/callback?code=auth-code-abc&state=state-xyz" + )) + .send() + .await + .unwrap(); + + let (code, state) = acceptor.await.unwrap().unwrap(); + assert_eq!(code, "auth-code-abc"); + assert_eq!(state, "state-xyz"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_loopback_handshake_rejects_missing_code() { + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let acceptor = tokio::spawn(async move { accept_callback(&listener, "/callback").await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/callback?state=only-state")) + .send() + .await + .unwrap(); + + let err = acceptor.await.unwrap().unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("missing")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_loopback_handshake_surfaces_authorization_error_param() { + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let acceptor = tokio::spawn(async move { accept_callback(&listener, "/callback").await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = reqwest::Client::new() + .get(format!( + "http://127.0.0.1:{port}/callback?error=access_denied&error_description=user+denied" + )) + .send() + .await + .unwrap(); + + let err = acceptor.await.unwrap().unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("access_denied")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_loopback_ignores_favicon_and_accepts_callback() { + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let acceptor = tokio::spawn(async move { accept_callback(&listener, "/callback").await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + let client = reqwest::Client::new(); + // Browser preflight that the listener should ignore. + let _ = client + .get(format!("http://127.0.0.1:{port}/favicon.ico")) + .send() + .await + .unwrap(); + let _ = client + .get(format!( + "http://127.0.0.1:{port}/callback?code=c1&state=s1" + )) + .send() + .await + .unwrap(); + + let (code, state) = acceptor.await.unwrap().unwrap(); + assert_eq!(code, "c1"); + assert_eq!(state, "s1"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn pkce_loopback_honors_custom_callback_path() { + // A custom registered redirect path (e.g. `/oauth/callback`) must be served by the + // listener — not just advertised in the authorize URL — or the browser callback 404s and + // login hangs. The listener accepts the configured path and ignores the default one. + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let acceptor = + tokio::spawn(async move { accept_callback(&listener, "/oauth/callback").await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + let client = reqwest::Client::new(); + // The old default path must now be ignored (404), not treated as the callback. + let _ = client + .get(format!("http://127.0.0.1:{port}/callback?code=wrong&state=wrong")) + .send() + .await + .unwrap(); + let _ = client + .get(format!( + "http://127.0.0.1:{port}/oauth/callback?code=c2&state=s2" + )) + .send() + .await + .unwrap(); + + let (code, state) = acceptor.await.unwrap().unwrap(); + assert_eq!(code, "c2"); + assert_eq!(state, "s2"); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn pkce_state_mismatch_aborts() { + // Bind a port; spawn the full flow with a mocked browser that + // returns a state DIFFERENT from what the flow generated. + // Since the flow generates state internally and we can't inject + // it, we replicate the behavior of run_pkce up to the state check + // by calling accept_callback directly with a mismatched state. + // This isn't a true e2e test, but it does check the assertion + // path inside run_pkce. + let port = pick_free_port(); + let listener = TcpListener::bind(("127.0.0.1", port)).await.unwrap(); + let acceptor = tokio::spawn(async move { accept_callback(&listener, "/callback").await }); + + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = reqwest::Client::new() + .get(format!( + "http://127.0.0.1:{port}/callback?code=c&state=attacker-state" + )) + .send() + .await + .unwrap(); + + let (code, state) = acceptor.await.unwrap().unwrap(); + assert_eq!(state, "attacker-state"); + assert_eq!(code, "c"); + // run_pkce would now compare state against its own generated + // value and bail; we assert the comparator logic inline: + let expected_state = "expected-state"; + assert_ne!(state, expected_state); + } + + #[tokio::test(flavor = "multi_thread")] + #[serial] + async fn provider_treats_raw_string_as_unexpired_bearer() { + // --with-token paste populates the keyring with a raw string, + // not a JSON bundle. Provider should use it directly. + let mock = Arc::new(MockKeyringStore::new()); + mock.set("my-cli", "OAuth2", "raw-pasted-token").unwrap(); + set_active_store(mock); + let p = OAuth2KeyringProvider::new("OAuth2", "my-cli", "https://unused", "client"); + let r = p + .apply(reqwest::Client::new().get("https://example.com"), &EndpointAuthMetadata::unspecified()) + .unwrap() + .build() + .unwrap(); + let auth = r.headers().get("authorization").unwrap().to_str().unwrap(); + assert_eq!(auth, "Bearer raw-pasted-token"); + } +} diff --git a/src/auth/provider.rs b/src/auth/provider.rs new file mode 100644 index 0000000..878eeaa --- /dev/null +++ b/src/auth/provider.rs @@ -0,0 +1,215 @@ +//! The [`AuthProvider`] trait, its per-request metadata +//! ([`EndpointAuthMetadata`]), the [`DynAuthProvider`] handle alias, and +//! the [`NoAuthProvider`] sentinel. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::error::CliError; + +/// Per-request context the executor passes to providers. Maps directly to +/// the TS generator's `endpointMetadata` argument. +/// +/// Three states encode OpenAPI's three semantics: +/// - `None` — the operation didn't pin a security policy. The composition +/// wrapper's default (typically `AnyAuthProvider`) handles it. +/// - `Some(vec![])` — explicitly anonymous (`security: []` in the spec). +/// The provider must not attach any auth, even if credentials are available. +/// - `Some(vec![req1, req2, ...])` — OR-of-ANDs: satisfy any one requirement. +#[derive(Debug, Clone, Default)] +pub struct EndpointAuthMetadata { + pub security_requirements: Option>>>, + pub base_url_override: Option, +} + +impl EndpointAuthMetadata { + /// No security policy declared on the operation — let the wrapper's + /// default policy decide. + pub fn unspecified() -> Self { + Self::default() + } + + /// `security: []` in the spec — operation is explicitly unauthenticated. + pub fn explicit_anonymous() -> Self { + Self { + security_requirements: Some(Vec::new()), + base_url_override: None, + } + } + + pub fn with_requirements(reqs: Vec>>) -> Self { + Self { + security_requirements: Some(reqs), + base_url_override: None, + } + } + + pub fn with_base_url_override(mut self, base_url_override: Option<&str>) -> Self { + self.base_url_override = base_url_override.map(str::to_string); + self + } + + /// True when the operation pinned `security: []` — the spec's "this + /// endpoint is explicitly unauthenticated" signal. The executor uses + /// this to short-circuit `apply` so credentials never leak onto an + /// opt-out endpoint, regardless of which provider is configured. + pub fn is_explicit_anonymous(&self) -> bool { + matches!(&self.security_requirements, Some(reqs) if reqs.is_empty()) + } +} + +/// A pluggable authentication scheme. +/// +/// Implementors mutate `request` with the appropriate headers (or other +/// modifications) for an outgoing API call. Returning the request unchanged +/// is the right behaviour when the provider can't satisfy this request and +/// composition wrappers should fall through to the next provider. +/// +/// # Repeated credential resolution +/// +/// Composition wrappers (`AnyAuthProvider`, `AllAuthProvider`, +/// `RoutingAuthProvider`) call `has_credentials` / `has_credentials_for` +/// before `apply` on each request, so an +/// [`AuthCredentialSource`](crate::auth::AuthCredentialSource) backing a +/// leaf provider can be resolved twice (or more, through nested wrappers). +/// For `Env` / `Literal` / `Cli` sources this is free; for `File` it means +/// a re-read on each call and for `Closure` it means re-invocation. This +/// is acceptable for the CLI workload (one request per process invocation), +/// but provider implementations that wrap an expensive source — token +/// refresh, keychain access, network round-trips — should memoize +/// internally rather than expect the trait to deduplicate calls. +pub trait AuthProvider: Send + Sync + std::fmt::Debug { + /// Stable identifier. Used by [`RoutingAuthProvider`][rap] to look up + /// the provider for a security requirement and by error messages. Should + /// match the scheme name from the OpenAPI spec where applicable. + /// + /// [rap]: crate::auth::RoutingAuthProvider + fn name(&self) -> &str; + + /// Whether this provider currently has *any* credential available. + /// Used by composition wrappers to decide whether to try this provider + /// at all (e.g., `AnyAuthProvider` skips children whose + /// `has_credentials()` is false). + /// + /// For "could this provider have authenticated *this specific + /// endpoint*?" — used by the friendly-error path on 401/403 — see + /// [`has_credentials_for`](Self::has_credentials_for) instead. + fn has_credentials(&self) -> bool; + + /// Whether this provider can satisfy *this specific endpoint*'s auth + /// requirements. Used by the error path to decide whether a 401/403 is + /// the user's fault (no creds for this endpoint → friendly error) or + /// actually a server problem (creds were sent → surface raw error). + /// + /// The default delegates to [`has_credentials`](Self::has_credentials), + /// which is correct for leaf providers (bearer, basic, header) and for + /// `AnyAuthProvider` (any provider with creds will be tried regardless + /// of endpoint). Composition wrappers that route by endpoint — + /// notably [`RoutingAuthProvider`] — should override this to inspect + /// the endpoint's `security_requirements` and check whether any + /// requirement is satisfiable. + fn has_credentials_for(&self, _endpoint: &EndpointAuthMetadata) -> bool { + self.has_credentials() + } + + /// Human-readable hints about where this provider reads its credentials + /// from. Used by the friendly auth-error path to tell the user which + /// env var / CLI flag / file to set. + fn credential_hints(&self) -> Vec { + Vec::new() + } + + /// Apply the scheme to `request`. Implementations should be a no-op if + /// they can't satisfy the request (e.g., no env var set), so wrappers can + /// fall through. Hard errors (malformed token bytes) are surfaced via + /// [`CliError::Auth`]. + fn apply( + &self, + request: reqwest::RequestBuilder, + endpoint: &EndpointAuthMetadata, + ) -> Result; + + /// Post-construction hook: inject the on-disk token cache for + /// cross-invocation persistence. Called by [`CliApp`] in + /// `propagate_root_auth` once it knows the binary name. + /// + /// Default is a no-op. [`OAuth2TokenProvider`](crate::auth::OAuth2TokenProvider) + /// overrides this to wire [`TokenCache`](crate::auth::oauth2::TokenCache). + fn inject_token_cache(&self, _cli_name: &str) {} +} + +/// Boxed handle the rest of the codebase passes around. +pub type DynAuthProvider = Arc; + +/// Construct a no-op [`AuthProvider`] handle. Use this in tests and in +/// custom command handlers that want to bypass auth for a one-off call. +pub fn no_auth_provider() -> DynAuthProvider { + Arc::new(NoAuthProvider) +} + +/// No-op provider. Used when the CLI hasn't configured auth at all. +#[derive(Debug, Clone, Default)] +pub struct NoAuthProvider; + +impl AuthProvider for NoAuthProvider { + fn name(&self) -> &str { + "none" + } + + fn has_credentials(&self) -> bool { + false + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + Ok(request) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, req}; + + #[tokio::test] + async fn no_auth_provider_emits_no_headers() { + let p = NoAuthProvider; + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + assert!(!p.has_credentials()); + assert_eq!(p.name(), "none"); + } + + #[test] + fn endpoint_metadata_three_states() { + // `unspecified` and `default` agree. + assert!(EndpointAuthMetadata::unspecified() + .security_requirements + .is_none()); + assert!(EndpointAuthMetadata::default() + .security_requirements + .is_none()); + + // `explicit_anonymous` is `Some(empty)`. + let anon = EndpointAuthMetadata::explicit_anonymous(); + assert_eq!( + anon.security_requirements.as_ref().map(|v| v.len()), + Some(0), + ); + + // `with_requirements` carries them through. + let reqs = vec![{ + let mut m = HashMap::new(); + m.insert("a".to_string(), Vec::::new()); + m + }]; + let with = EndpointAuthMetadata::with_requirements(reqs); + assert_eq!( + with.security_requirements.as_ref().map(|v| v.len()), + Some(1), + ); + } +} diff --git a/src/auth/root_builder.rs b/src/auth/root_builder.rs new file mode 100644 index 0000000..2d635e4 --- /dev/null +++ b/src/auth/root_builder.rs @@ -0,0 +1,619 @@ +//! Typed auth-scheme builders for root-level `CliApp` registration. +//! +//! These builders provide a type-safe, discoverable API for declaring auth +//! at the CLI level. Each builder produces the underlying `(String, SchemeBinding)` +//! pair consumed by the existing auth infrastructure. +//! +//! # Example +//! +//! ```rust,no_run +//! use fern_cli_sdk::app::CliApp; +//! use fern_cli_sdk::auth::{BearerAuth, ApiKeyAuth, BasicAuth, OAuth2Auth}; +//! use fern_cli_sdk::openapi::OpenApiBinding; +//! +//! CliApp::new("platform") +//! .auth(BearerAuth::new("bearerAuth").env("PLATFORM_TOKEN")) +//! .auth(ApiKeyAuth::new("apiKey").env("API_KEY")) +//! .auth(BasicAuth::new("basicAuth").username_env("USER").password_env("PASS")) +//! .auth(OAuth2Auth::new("OAuth2Security").client_id_env("ID").client_secret_env("SECRET").token_url("https://auth.example.com/token")) +//! .binding(OpenApiBinding::new().spec("openapi: '3.0.0'\ninfo:\n title: x\n version: '1'\npaths: {}")) +//! .run(); +//! ``` + +use std::sync::Arc; + +use super::builder::SchemeBinding; +use super::credential::AuthCredentialSource; +use super::oauth2::{MisconfiguredOAuth2Provider, OAuth2Grant, OAuth2TokenProvider}; +use super::oauth2_contract::OAuth2Endpoint; +use super::provider::DynAuthProvider; + +/// Trait implemented by all typed auth builders. Converts the builder +/// into the `(scheme_name, SchemeBinding)` pair used by the auth +/// infrastructure. +pub trait AuthSchemeBuilder { + /// Consume the builder and produce a `(scheme_name, SchemeBinding)` pair. + fn into_binding(self) -> (String, SchemeBinding); +} + +// --------------------------------------------------------------------------- +// BearerAuth — Authorization: Bearer +// --------------------------------------------------------------------------- + +/// Builder for bearer token authentication (`Authorization: Bearer `). +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +#[derive(Debug, Clone)] +pub struct BearerAuth { + name: String, + source: AuthCredentialSource, +} + +impl BearerAuth { + /// Create a new bearer auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + source: AuthCredentialSource::Missing, + } + } + + /// Read the bearer token from an environment variable. + pub fn env(mut self, var_name: impl Into) -> Self { + self.source = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the bearer token from a CLI flag (`--`). + pub fn cli(mut self, arg_name: impl Into) -> Self { + self.source = AuthCredentialSource::cli(arg_name); + self + } + + /// Read the bearer token from a file. + pub fn file(mut self, path: impl Into) -> Self { + self.source = AuthCredentialSource::file(path.into()); + self + } + + /// Use a fallback chain: try env, then CLI, then file, etc. + pub fn source(mut self, source: AuthCredentialSource) -> Self { + self.source = source; + self + } +} + +impl AuthSchemeBuilder for BearerAuth { + fn into_binding(self) -> (String, SchemeBinding) { + (self.name, SchemeBinding::Token(self.source)) + } +} + +// --------------------------------------------------------------------------- +// ApiKeyAuth — header or query-parameter API key +// --------------------------------------------------------------------------- + +/// Builder for API key authentication (header-based or query-parameter). +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +/// The header name is read from the spec's `in: header` / `name: X-API-Key` +/// declaration; it does NOT need to be set here unless overriding. +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + name: String, + source: AuthCredentialSource, +} + +impl ApiKeyAuth { + /// Create a new API key auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + source: AuthCredentialSource::Missing, + } + } + + /// Read the API key from an environment variable. + pub fn env(mut self, var_name: impl Into) -> Self { + self.source = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the API key from a CLI flag (`--`). + pub fn cli(mut self, arg_name: impl Into) -> Self { + self.source = AuthCredentialSource::cli(arg_name); + self + } + + /// Read the API key from a file. + pub fn file(mut self, path: impl Into) -> Self { + self.source = AuthCredentialSource::file(path.into()); + self + } + + /// Use a custom credential source. + pub fn source(mut self, source: AuthCredentialSource) -> Self { + self.source = source; + self + } +} + +impl AuthSchemeBuilder for ApiKeyAuth { + fn into_binding(self) -> (String, SchemeBinding) { + (self.name, SchemeBinding::Token(self.source)) + } +} + +// --------------------------------------------------------------------------- +// BasicAuth — HTTP Basic authentication +// --------------------------------------------------------------------------- + +/// Builder for HTTP Basic authentication (`Authorization: Basic base64(user:pass)`). +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +#[derive(Debug, Clone)] +pub struct BasicAuth { + name: String, + username: AuthCredentialSource, + password: AuthCredentialSource, +} + +impl BasicAuth { + /// Create a new basic auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + username: AuthCredentialSource::Missing, + password: AuthCredentialSource::Missing, + } + } + + /// Read the username from an environment variable. + pub fn username_env(mut self, var_name: impl Into) -> Self { + self.username = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the password from an environment variable. + pub fn password_env(mut self, var_name: impl Into) -> Self { + self.password = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the username from a CLI flag. + pub fn username_cli(mut self, arg_name: impl Into) -> Self { + self.username = AuthCredentialSource::cli(arg_name); + self + } + + /// Read the password from a CLI flag. + pub fn password_cli(mut self, arg_name: impl Into) -> Self { + self.password = AuthCredentialSource::cli(arg_name); + self + } + + /// Set a custom credential source for the username. + pub fn username_source(mut self, source: AuthCredentialSource) -> Self { + self.username = source; + self + } + + /// Set a custom credential source for the password. + pub fn password_source(mut self, source: AuthCredentialSource) -> Self { + self.password = source; + self + } +} + +impl AuthSchemeBuilder for BasicAuth { + fn into_binding(self) -> (String, SchemeBinding) { + ( + self.name, + SchemeBinding::Basic { + username: self.username, + password: self.password, + }, + ) + } +} + +// --------------------------------------------------------------------------- +// OAuth2Auth — OAuth2 flows (client-credentials, refresh-token, PKCE) +// --------------------------------------------------------------------------- + +/// Builder for OAuth2 authentication. +/// +/// The scheme name must match the `securitySchemes` key in the binding's spec. +/// The token URL is embedded by the generator (from the spec's +/// `securitySchemes.*.flows.clientCredentials.tokenUrl` or Fern IR). +/// +/// At runtime, this resolves to a bearer token — the OAuth2 flow is +/// handled by the binding's executor using the token URL and credentials +/// declared here. +#[derive(Debug, Clone)] +pub struct OAuth2Auth { + name: String, + client_id: AuthCredentialSource, + client_secret: AuthCredentialSource, + access_token: AuthCredentialSource, + refresh_token: AuthCredentialSource, + token_url: Option, + token_endpoint: Option, + refresh_endpoint: Option, + token_header: String, + token_prefix: String, + scopes: Vec, +} + +impl OAuth2Auth { + /// Create a new OAuth2 auth builder. `name` must match the scheme name + /// declared in the spec's `components.securitySchemes`. + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + client_id: AuthCredentialSource::Missing, + client_secret: AuthCredentialSource::Missing, + access_token: AuthCredentialSource::Missing, + refresh_token: AuthCredentialSource::Missing, + token_url: None, + token_endpoint: None, + refresh_endpoint: None, + token_header: "Authorization".to_string(), + token_prefix: "Bearer".to_string(), + scopes: Vec::new(), + } + } + + /// Set the OAuth2 token endpoint URL (from spec or Fern IR). + pub fn token_url(mut self, url: impl Into) -> Self { + self.token_url = Some(url.into()); + self + } + + /// Configure the IR-derived token endpoint contract. + pub fn token_endpoint(mut self, endpoint: OAuth2Endpoint) -> Self { + self.token_endpoint = Some(endpoint); + self + } + + /// Configure the IR-derived refresh endpoint contract. + pub fn refresh_endpoint(mut self, endpoint: OAuth2Endpoint) -> Self { + self.refresh_endpoint = Some(endpoint); + self + } + + /// Header used to authenticate protected API requests. + pub fn token_header(mut self, header: impl Into) -> Self { + self.token_header = header.into(); + self + } + + /// Prefix prepended to the access token. An empty prefix sends the raw token. + pub fn token_prefix(mut self, prefix: impl Into) -> Self { + self.token_prefix = prefix.into(); + self + } + + /// Request these scopes during the client-credentials exchange. + pub fn scopes(mut self, scopes: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.scopes = scopes.into_iter().map(Into::into).collect(); + self + } + + /// Read the client ID from an environment variable. + pub fn client_id_env(mut self, var_name: impl Into) -> Self { + self.client_id = AuthCredentialSource::from_env(var_name); + self + } + + /// Read the client secret from an environment variable. + pub fn client_secret_env(mut self, var_name: impl Into) -> Self { + self.client_secret = AuthCredentialSource::from_env(var_name); + self + } + + /// Read a static access token from an environment variable. + /// If set and resolvable, this bypasses the client-credentials flow. + pub fn access_token_env(mut self, var_name: impl Into) -> Self { + self.access_token = AuthCredentialSource::from_env(var_name); + self + } + + /// Read a refresh token from an environment variable. + pub fn refresh_token_env(mut self, var_name: impl Into) -> Self { + self.refresh_token = AuthCredentialSource::from_env(var_name); + self + } + + /// Set a custom credential source for the client ID. + pub fn client_id_source(mut self, source: AuthCredentialSource) -> Self { + self.client_id = source; + self + } + + /// Set a custom credential source for the client secret. + pub fn client_secret_source(mut self, source: AuthCredentialSource) -> Self { + self.client_secret = source; + self + } + + /// Set a custom credential source for the access token. + pub fn access_token_source(mut self, source: AuthCredentialSource) -> Self { + self.access_token = source; + self + } + + /// Set a custom credential source for the refresh token. + pub fn refresh_token_source(mut self, source: AuthCredentialSource) -> Self { + self.refresh_token = source; + self + } + + /// Get the token URL, if set. + pub fn get_token_url(&self) -> Option<&str> { + self.token_url.as_deref() + } + + /// Get the client ID source. + pub fn get_client_id(&self) -> &AuthCredentialSource { + &self.client_id + } + + /// Get the client secret source. + pub fn get_client_secret(&self) -> &AuthCredentialSource { + &self.client_secret + } + + /// Get the access token source. + pub fn get_access_token(&self) -> &AuthCredentialSource { + &self.access_token + } + + /// Get the refresh token source. + pub fn get_refresh_token(&self) -> &AuthCredentialSource { + &self.refresh_token + } +} + +impl AuthSchemeBuilder for OAuth2Auth { + fn into_binding(self) -> (String, SchemeBinding) { + // A static access token bypasses the OAuth flow entirely — surface it + // as a plain bearer Token binding (lowered to a BearerAuthProvider). + if !matches!(self.access_token, AuthCredentialSource::Missing) { + return (self.name, SchemeBinding::Token(self.access_token)); + } + + // No static token: actually wire the client-credentials / refresh-token + // flow so the CLI obtains a token and authenticates — rather than + // silently sending unauthenticated requests (FER-10745). The previous + // behavior collapsed to `Token(Missing)`, which lowered to a bearer + // provider with no credential and no Authorization header. + // + // `OAuth2Grant` reads credentials from environment variables at refresh + // time, so we need the env-var *names* behind the client_id / + // client_secret / refresh_token sources. Non-env sources can't feed + // that grant; we treat them (and any missing token_url) as incomplete + // config and fail fast at request time instead of authenticating + // silently. + let has_token_endpoint = self.token_endpoint.is_some(); + let provider: DynAuthProvider = match ( + self.token_endpoint, + self.token_url.as_deref(), + self.client_id.env_var_name(), + self.client_secret.env_var_name(), + ) { + (Some(token_endpoint), _, Some(client_id_env), Some(client_secret_env)) => { + Arc::new(OAuth2TokenProvider::from_client_credentials( + self.name.clone(), + client_id_env, + client_secret_env, + self.scopes, + token_endpoint, + self.refresh_endpoint, + self.token_header, + self.token_prefix, + )) + } + (None, Some(token_url), Some(client_id_env), Some(client_secret_env)) => { + let grant = match self.refresh_token.env_var_name() { + Some(refresh_token_env) => OAuth2Grant::RefreshToken { + client_id_env: client_id_env.to_string(), + client_secret_env: client_secret_env.to_string(), + refresh_token_env: refresh_token_env.to_string(), + }, + None if matches!(self.refresh_token, AuthCredentialSource::Missing) => { + OAuth2Grant::ClientCredentials { + client_id_env: client_id_env.to_string(), + client_secret_env: client_secret_env.to_string(), + scope: if self.scopes.is_empty() { + None + } else { + Some(self.scopes.join(" ")) + }, + } + } + None => { + // Non-env refresh token source (literal, file, closure, + // etc.) — OAuth2Grant can't consume it. Fail fast. + return ( + self.name.clone(), + SchemeBinding::Custom(Arc::new(MisconfiguredOAuth2Provider::new( + self.name, + "refresh_token configured via non-env source; \ + OAuth2Grant only supports env-var credentials" + .to_string(), + ))), + ); + } + }; + Arc::new( + OAuth2TokenProvider::new(self.name.clone(), token_url.to_string(), grant) + .with_token_application(self.token_header, self.token_prefix), + ) + } + _ => Arc::new(MisconfiguredOAuth2Provider::new( + self.name.clone(), + oauth2_missing_config_reason( + self.token_url.is_some() || has_token_endpoint, + self.client_id.env_var_name().is_some(), + self.client_secret.env_var_name().is_some(), + ), + )), + }; + + (self.name, SchemeBinding::Custom(provider)) + } +} + +/// Build a human-readable reason listing which pieces of OAuth2 +/// client-credentials config are missing, for the fail-fast provider's error +/// message. The env-var checks are `true` only when the corresponding source +/// is an `Env` source (the only kind `OAuth2Grant` can read). +fn oauth2_missing_config_reason( + has_token_url: bool, + has_client_id_env: bool, + has_client_secret_env: bool, +) -> String { + let mut missing = Vec::new(); + if !has_token_url { + missing.push("token_url"); + } + if !has_client_id_env { + missing.push("client_id (env-var source)"); + } + if !has_client_secret_env { + missing.push("client_secret (env-var source)"); + } + format!("missing OAuth2 config: {}", missing.join(", ")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bearer_auth_builds_token_binding() { + let (name, binding) = BearerAuth::new("bearerAuth").env("MY_TOKEN").into_binding(); + assert_eq!(name, "bearerAuth"); + assert!( + matches!(binding, SchemeBinding::Token(AuthCredentialSource::Env(ref e)) if e == "MY_TOKEN") + ); + } + + #[test] + fn api_key_auth_builds_token_binding() { + let (name, binding) = ApiKeyAuth::new("apiKey").env("API_KEY").into_binding(); + assert_eq!(name, "apiKey"); + assert!( + matches!(binding, SchemeBinding::Token(AuthCredentialSource::Env(ref e)) if e == "API_KEY") + ); + } + + #[test] + fn basic_auth_builds_basic_binding() { + let (name, binding) = BasicAuth::new("httpBasic") + .username_env("USER") + .password_env("PASS") + .into_binding(); + assert_eq!(name, "httpBasic"); + match binding { + SchemeBinding::Basic { username, password } => { + assert!(matches!(username, AuthCredentialSource::Env(ref e) if e == "USER")); + assert!(matches!(password, AuthCredentialSource::Env(ref e) if e == "PASS")); + } + _ => panic!("expected Basic binding"), + } + } + + #[test] + fn oauth2_auth_with_static_token() { + let (name, binding) = OAuth2Auth::new("OAuth2Security") + .access_token_env("MY_ACCESS_TOKEN") + .token_url("https://auth.example.com/token") + .into_binding(); + assert_eq!(name, "OAuth2Security"); + assert!( + matches!(binding, SchemeBinding::Token(AuthCredentialSource::Env(ref e)) if e == "MY_ACCESS_TOKEN") + ); + } + + // FER-10745: without a static access token, the client-credentials flow + // must actually be wired (a Custom OAuth2 provider), NOT collapsed to a + // credential-less bearer that silently sends unauthenticated requests. + #[test] + fn oauth2_auth_client_credentials_wires_oauth_provider() { + let (name, binding) = OAuth2Auth::new("OAuth2Security") + .client_id_env("CLIENT_ID") + .client_secret_env("CLIENT_SECRET") + .token_url("https://auth.example.com/token") + .into_binding(); + assert_eq!(name, "OAuth2Security"); + let SchemeBinding::Custom(provider) = binding else { + panic!("client-credentials OAuth2 should lower to a Custom provider"); + }; + // The wired provider reads the configured client-cred env vars. + let hints = provider.credential_hints().join(" "); + assert!(hints.contains("CLIENT_ID"), "hints: {hints}"); + assert!(hints.contains("CLIENT_SECRET"), "hints: {hints}"); + } + + #[test] + fn oauth2_auth_refresh_token_wires_oauth_provider() { + let (_, binding) = OAuth2Auth::new("OAuth2Security") + .client_id_env("CLIENT_ID") + .client_secret_env("CLIENT_SECRET") + .refresh_token_env("REFRESH_TOKEN") + .token_url("https://auth.example.com/token") + .into_binding(); + let SchemeBinding::Custom(provider) = binding else { + panic!("refresh-token OAuth2 should lower to a Custom provider"); + }; + let hints = provider.credential_hints().join(" "); + assert!(hints.contains("REFRESH_TOKEN"), "hints: {hints}"); + } + + // FER-10745: incomplete config (no token_url / non-env creds) must fail + // fast — selected by composition (has_credentials == true) so it errors + // loudly rather than being skipped into a silent unauthenticated request. + #[test] + fn oauth2_auth_incomplete_config_fails_fast_not_silent() { + let (_, binding) = OAuth2Auth::new("OAuth2Security") + .client_id_env("CLIENT_ID") + .client_secret_env("CLIENT_SECRET") + // no token_url + .into_binding(); + let SchemeBinding::Custom(provider) = binding else { + panic!("incomplete OAuth2 should still lower to a Custom provider"); + }; + assert!( + provider.has_credentials(), + "must be selected (not skipped) so the misconfig surfaces loudly", + ); + } + + // Non-env refresh_token source must fail fast rather than silently + // falling back to client-credentials grant. + #[test] + fn oauth2_auth_non_env_refresh_token_fails_fast() { + let (_, binding) = OAuth2Auth::new("OAuth2Security") + .client_id_env("CLIENT_ID") + .client_secret_env("CLIENT_SECRET") + .refresh_token_source(AuthCredentialSource::literal("my-refresh-token")) + .token_url("https://auth.example.com/token") + .into_binding(); + let SchemeBinding::Custom(provider) = binding else { + panic!("non-env refresh token should lower to a Custom provider"); + }; + assert!( + provider.has_credentials(), + "must be selected (not skipped) so the misconfig surfaces loudly", + ); + } +} diff --git a/src/auth/schemes.rs b/src/auth/schemes.rs new file mode 100644 index 0000000..c5704e4 --- /dev/null +++ b/src/auth/schemes.rs @@ -0,0 +1,447 @@ +//! Concrete auth-scheme providers: bearer tokens, HTTP basic, and arbitrary +//! header-bound credentials. Each one is a small wrapper around an +//! [`AuthCredentialSource`] that knows how to format the resolved value as +//! an outgoing header. +//! +//! # Secret-handling tradeoff +//! +//! Each `apply` formats the outgoing header by `expose_secret`-ing the +//! resolved [`SecretString`] into a transient `String` buffer (e.g. +//! `"Bearer " + token`). That buffer is not zeroized on drop. We accept the +//! transient unprotected copy because the `HeaderValue` it lowers into +//! (and the resulting on-the-wire `reqwest::Request` body) is not zeroized +//! either — adding zeroization here without doing it end-to-end would be +//! security theater. The mitigations still in force: `set_sensitive(true)` +//! on every produced `HeaderValue` so reqwest redacts it in `Debug`, and +//! `SecretString`'s redacting `Debug`/`Display` impl at the source. + +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use secrecy::ExposeSecret; + +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::{AuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// BearerAuthProvider — Authorization: Bearer +// --------------------------------------------------------------------------- + +/// `Authorization: Bearer ` (RFC 6750). +#[derive(Debug, Clone)] +pub struct BearerAuthProvider { + name: String, + token: AuthCredentialSource, +} + +impl BearerAuthProvider { + pub fn new(name: impl Into, token: AuthCredentialSource) -> Self { + Self { + name: name.into(), + token, + } + } +} + +impl AuthProvider for BearerAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.token.resolve().is_some() + } + + fn credential_hints(&self) -> Vec { + self.token.credential_hints() + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let Some(token) = self.token.resolve() else { + return Ok(request); + }; + // Avoid `RequestBuilder::bearer_auth` — it panics on tokens with + // bytes that can't be a HeaderValue (CTL chars, NUL, non-ASCII). + // AGENTS.md flags adversarial inputs explicitly. + let mut value = String::with_capacity(7 + token.expose_secret().len()); + value.push_str("Bearer "); + value.push_str(token.expose_secret()); + let mut header = reqwest::header::HeaderValue::from_str(&value) + .map_err(|e| CliError::Auth(format!("Invalid bearer token: {e}")))?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// BasicAuthProvider — Authorization: Basic base64(user:pass) +// --------------------------------------------------------------------------- + +/// `Authorization: Basic base64(username:password)` (RFC 7617). +/// +/// Three construction modes: +/// +/// | Constructor | `has_credentials` requires | Omitted field sent as | +/// |---|---|---| +/// | [`new`](Self::new) | both username **and** password | — | +/// | [`username_only`](Self::username_only) | username | password = `""` | +/// | [`password_only`](Self::password_only) | password | username = `""` | +#[derive(Debug, Clone)] +pub struct BasicAuthProvider { + name: String, + username: AuthCredentialSource, + password: AuthCredentialSource, + mode: BasicAuthMode, +} + +/// Controls which credentials [`BasicAuthProvider`] requires. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BasicAuthMode { + /// Both username and password must resolve. + Full, + /// Only the username must resolve; password is sent as `""`. + UsernameOnly, + /// Only the password must resolve; username is sent as `""`. + PasswordOnly, +} + +impl BasicAuthProvider { + /// Standard HTTP Basic auth — both username and password are required. + pub fn new( + name: impl Into, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username, + password, + mode: BasicAuthMode::Full, + } + } + + /// Username-only Basic auth (empty password). Common for APIs that + /// accept an API key as the HTTP Basic username (e.g. Close CRM). + pub fn username_only( + name: impl Into, + username: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username, + password: AuthCredentialSource::Missing, + mode: BasicAuthMode::UsernameOnly, + } + } + + /// Password-only Basic auth (empty username). Used by APIs that + /// expect the token in the password field of HTTP Basic. + pub fn password_only( + name: impl Into, + password: AuthCredentialSource, + ) -> Self { + Self { + name: name.into(), + username: AuthCredentialSource::Missing, + password, + mode: BasicAuthMode::PasswordOnly, + } + } +} + +impl AuthProvider for BasicAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + match self.mode { + BasicAuthMode::Full => { + self.username.resolve().is_some() && self.password.resolve().is_some() + } + BasicAuthMode::UsernameOnly => self.username.resolve().is_some(), + BasicAuthMode::PasswordOnly => self.password.resolve().is_some(), + } + } + + fn credential_hints(&self) -> Vec { + let mut hints = self.username.credential_hints(); + hints.extend(self.password.credential_hints()); + hints + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let u = self.username.resolve(); + let p = self.password.resolve(); + + // In Full mode both must be present; in partial modes the + // omitted half is sent as the empty string. + match self.mode { + BasicAuthMode::Full if u.is_none() || p.is_none() => return Ok(request), + BasicAuthMode::UsernameOnly if u.is_none() => return Ok(request), + BasicAuthMode::PasswordOnly if p.is_none() => return Ok(request), + _ => {} + } + + let u_ref = u.as_ref().map(|s| s.expose_secret()).unwrap_or(""); + let p_ref = p.as_ref().map(|s| s.expose_secret()).unwrap_or(""); + + let mut combined = String::with_capacity(u_ref.len() + 1 + p_ref.len()); + combined.push_str(u_ref); + combined.push(':'); + combined.push_str(p_ref); + let encoded = BASE64.encode(&combined); + let value = format!("Basic {encoded}"); + let mut header = + reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + CliError::Auth(format!("Invalid basic-auth credentials: {e}")) + })?; + header.set_sensitive(true); + Ok(request.header(reqwest::header::AUTHORIZATION, header)) + } +} + +// --------------------------------------------------------------------------- +// HeaderAuthProvider — raw or bearer-prefixed token in a named header. +// --------------------------------------------------------------------------- + +/// Send the token verbatim in a named header. Used by APIs like Linear +/// (`Authorization: ` with no `Bearer ` prefix) and any custom +/// `X-Api-Key` style scheme. +/// +/// If `bearer_prefix` is true, the value is prefixed with `Bearer ` — +/// equivalent to a [`BearerAuthProvider`] but on a non-`Authorization` +/// header (the Square pattern). +#[derive(Debug, Clone)] +pub struct HeaderAuthProvider { + name: String, + header_name: String, + token: AuthCredentialSource, + bearer_prefix: bool, +} + +impl HeaderAuthProvider { + pub fn new( + name: impl Into, + header_name: impl Into, + token: AuthCredentialSource, + bearer_prefix: bool, + ) -> Self { + Self { + name: name.into(), + header_name: header_name.into(), + token, + bearer_prefix, + } + } +} + +impl AuthProvider for HeaderAuthProvider { + fn name(&self) -> &str { + &self.name + } + + fn has_credentials(&self) -> bool { + self.token.resolve().is_some() + } + + fn credential_hints(&self) -> Vec { + self.token.credential_hints() + } + + fn apply( + &self, + request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + let Some(token) = self.token.resolve() else { + return Ok(request); + }; + let value = if self.bearer_prefix { + let mut s = String::with_capacity(7 + token.expose_secret().len()); + s.push_str("Bearer "); + s.push_str(token.expose_secret()); + s + } else { + token.expose_secret().to_string() + }; + let mut header_value = + reqwest::header::HeaderValue::from_str(&value).map_err(|e| { + CliError::Auth(format!( + "Invalid token for header '{}': {e}", + self.header_name + )) + })?; + header_value.set_sensitive(true); + Ok(request.header(self.header_name.as_str(), header_value)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::test_helpers::{auth_header, header, req}; + + // -------- BearerAuthProvider -------- + + #[tokio::test] + async fn bearer_provider_emits_authorization_bearer() { + let p = BearerAuthProvider::new("bearerAuth", AuthCredentialSource::literal("tok")); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn bearer_provider_no_token_is_noop() { + let p = BearerAuthProvider::new("bearerAuth", AuthCredentialSource::Missing); + assert!(!p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r), None); + } + + #[tokio::test] + async fn bearer_provider_rejects_invalid_token_bytes() { + // A token containing a newline is not a valid HeaderValue. + // We must error, not panic — adversarial inputs are called out in + // AGENTS.md. + let p = BearerAuthProvider::new( + "bearerAuth", + AuthCredentialSource::literal("bad\ntoken"), + ); + let err = p + .apply(req(), &EndpointAuthMetadata::unspecified()) + .unwrap_err(); + assert!(matches!(err, CliError::Auth(_))); + } + + // -------- BasicAuthProvider -------- + + #[tokio::test] + async fn basic_provider_emits_base64_authorization() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::literal("alice"), + AuthCredentialSource::literal("hunter2"), + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("alice:hunter2") = "YWxpY2U6aHVudGVyMg==" + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YWxpY2U6aHVudGVyMg=="), + ); + } + + #[test] + fn basic_provider_full_missing_password_is_no_credentials() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::literal("alice"), + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + #[test] + fn basic_provider_full_missing_username_is_no_credentials() { + let p = BasicAuthProvider::new( + "basicAuth", + AuthCredentialSource::Missing, + AuthCredentialSource::literal("pass"), + ); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn basic_provider_username_only_sends_empty_password() { + let p = BasicAuthProvider::username_only( + "basicAuth", + AuthCredentialSource::literal("api_key_123"), + ); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64("api_key_123:") — colon present, empty password + assert_eq!( + auth_header(r).as_deref(), + Some("Basic YXBpX2tleV8xMjM6"), + ); + } + + #[test] + fn basic_provider_username_only_missing_is_no_credentials() { + let p = BasicAuthProvider::username_only( + "basicAuth", + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + #[tokio::test] + async fn basic_provider_password_only_sends_empty_username() { + let p = BasicAuthProvider::password_only( + "basicAuth", + AuthCredentialSource::literal("secret_token"), + ); + assert!(p.has_credentials()); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + // base64(":secret_token") + assert_eq!( + auth_header(r).as_deref(), + Some("Basic OnNlY3JldF90b2tlbg=="), + ); + } + + #[test] + fn basic_provider_password_only_missing_is_no_credentials() { + let p = BasicAuthProvider::password_only( + "basicAuth", + AuthCredentialSource::Missing, + ); + assert!(!p.has_credentials()); + } + + // -------- HeaderAuthProvider -------- + + #[tokio::test] + async fn header_provider_raw_value_no_prefix() { + let p = HeaderAuthProvider::new( + "linearKey", + "Authorization", + AuthCredentialSource::literal("lin_api_xxx"), + false, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(auth_header(r).as_deref(), Some("lin_api_xxx")); + } + + #[tokio::test] + async fn header_provider_bearer_prefix_named_header() { + let p = HeaderAuthProvider::new( + "squareKey", + "X-Auth", + AuthCredentialSource::literal("tok"), + true, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-auth").as_deref(), Some("Bearer tok")); + } + + #[tokio::test] + async fn header_provider_custom_header_name() { + let p = HeaderAuthProvider::new( + "apiKey", + "X-Api-Key", + AuthCredentialSource::literal("k"), + false, + ); + let r = p.apply(req(), &EndpointAuthMetadata::unspecified()).unwrap(); + assert_eq!(header(r, "x-api-key").as_deref(), Some("k")); + } +} diff --git a/src/auth/test_helpers.rs b/src/auth/test_helpers.rs new file mode 100644 index 0000000..e1b9dd6 --- /dev/null +++ b/src/auth/test_helpers.rs @@ -0,0 +1,53 @@ +//! Shared test fixtures used across the `auth` submodules. Compiled only +//! under `#[cfg(test)]`. + +use std::sync::Arc; + +use crate::auth::credential::AuthCredentialSource; +use crate::auth::provider::DynAuthProvider; +use crate::auth::schemes::{BearerAuthProvider, HeaderAuthProvider}; + +/// A bare `RequestBuilder` pointing at example.com. Tests only inspect the +/// resulting headers — the URL doesn't matter. +pub fn req() -> reqwest::RequestBuilder { + reqwest::Client::new().post("https://example.com/") +} + +/// Read the `Authorization` header back off a built request, if present. +pub fn auth_header(req: reqwest::RequestBuilder) -> Option { + let built = req.build().unwrap(); + built + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +/// Read an arbitrary header value back off a built request. +pub fn header(req: reqwest::RequestBuilder, name: &str) -> Option { + let built = req.build().unwrap(); + built + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +/// Pre-built bearer provider with a literal token. Used as a fixture +/// for tests that need a credential-bearing provider. +pub fn bearer(name: &str, token: &str) -> DynAuthProvider { + Arc::new(BearerAuthProvider::new( + name, + AuthCredentialSource::literal(token), + )) +} + +/// Pre-built header provider — convenience for the apiKey-style tests. +pub fn api_key(name: &str, header_name: &str, value: &str) -> DynAuthProvider { + Arc::new(HeaderAuthProvider::new( + name, + header_name, + AuthCredentialSource::literal(value), + false, + )) +} diff --git a/src/binding.rs b/src/binding.rs new file mode 100644 index 0000000..8342311 --- /dev/null +++ b/src/binding.rs @@ -0,0 +1,161 @@ +//! Binding trait — the async interface that protocol-specific adapters +//! (`OpenApiBinding`, `GraphqlBinding`) implement so the root [`CliApp`] +//! can compose them into a single CLI. +//! +//! [`CliApp`]: crate::app::CliApp + +use std::any::Any; +use std::future::Future; +use std::pin::Pin; + +use crate::auth::SchemeBinding; +use crate::error::CliError; + +/// A boxed future used by binding methods. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// Outcome of a binding dispatch — either a decoded JSON value ready for +/// the root hook pipeline, or a signal that the binding handled output +/// itself (e.g. `--dry-run`, binary download, streaming). +pub enum DispatchResult { + /// A decoded response value. The root `CliApp` will run + /// `transform_response` / `recover_error` hooks and then format it. + Value(serde_json::Value), + /// The binding already wrote output (dry-run, streaming, file download). + /// The root `CliApp` skips its own formatting. + Handled, +} + +/// The async interface every protocol adapter must implement. +/// +/// A binding owns one logical API surface (one or more specs sharing +/// auth / transport config). The root `CliApp` holds +/// `Vec>` and delegates to the matched binding after +/// resolving which subcommand the user invoked. +pub trait Binding: Send + Sync { + /// Human-readable name for this binding (used in diagnostics). + fn name(&self) -> &str; + + /// Called by `CliApp::binding()` to propagate the CLI name to this + /// binding. HTTP config, logging env vars, and base-URL resolution + /// are CLI-level concerns that derive from this name. + fn set_cli_name(&mut self, name: &str); + + /// Build the `clap::Command` subtree contributed by this binding. + /// The root `CliApp` merges all binding trees into one CLI. + fn build_command(&self) -> Result; + + /// Execute the matched operation and return the decoded response. + /// + /// `root_matches` are the full parse result (for global flags). + /// `sub_matches` are scoped to the matched leaf subcommand. + /// `op_path` is the resolved command path (e.g. `["users", "get"]`). + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + sub_matches: &'a clap::ArgMatches, + op_path: &'a [String], + ) -> BoxFuture<'a, Result>; + + /// Return the embedded API spec(s) as a YAML string. + /// + /// - `raw == false` → the **effective** spec: source with overlays + + /// overrides merged (what the CLI actually serves at runtime). + /// - `raw == true` → the **source** spec: byte-exact embedded YAML, + /// before any overlay/override processing. + /// + /// Returns `Ok(None)` for bindings that do not embed a spec (e.g. + /// GraphQL). Multi-spec bindings concatenate entries as a YAML + /// stream (`---`-delimited). + fn spec_document(&self, _raw: bool) -> Result, CliError> { + Ok(None) + } + + /// Build this binding's contribution to the `--schema` flag for the given + /// subcommand path. `--schema` is the agent-facing machine-readable + /// counterpart to `--help`: wherever a user could type `--help` for prose, + /// they can type `--schema` for the same scope rendered as JSON. + /// + /// Returns: + /// + /// - `Ok(Some(value))` — this binding owns the path; `value` is the JSON + /// to emit (path-scoped) or aggregate (empty path). + /// - `Ok(None)` — this binding does not own the path; the caller will try + /// the next binding. + /// - `Err(_)` — a real failure (e.g. the binding's spec failed to + /// prepare). The caller logs and continues to the next binding so one + /// broken binding cannot block schema output for the others. + /// + /// `path.is_empty()` is the "list everything I contribute" case. The + /// caller concatenates the array contributions across all bindings, so + /// multi-binding CLIs (e.g. REST + GraphQL) get a unified root view. + /// + /// Default: `Ok(None)`. Bindings that expose a discoverable surface + /// override this. + fn schema(&self, _path: &[String]) -> Result, CliError> { + Ok(None) + } + + /// Return a type-erased binding context for use by CLI-level custom + /// command handlers. `matches` are the full parse result (needed + /// to resolve global flags like server vars and global headers). + /// + /// Returns `None` by default. Concrete bindings return their + /// protocol-specific `AppContext` (e.g. `openapi::AppContext`). + fn binding_context( + &self, + _matches: &clap::ArgMatches, + ) -> Result>, CliError> { + Ok(None) + } + + /// Receive root-level auth scheme bindings. Called by `CliApp` + /// before `build_command()` so the binding can incorporate root auth + /// into its command tree (help footer, global flags) and dispatch. + /// + /// Default: no-op. Bindings that support root-level auth override this. + fn set_root_auth(&mut self, _bindings: &[(String, SchemeBinding)]) {} + + /// Receive root-level global parameters. Called by `CliApp` before + /// `build_command()` so the binding can register them as top-level + /// flags and inject them into outgoing requests. Mirrors + /// [`set_root_auth`](Self::set_root_auth): global parameters are + /// declared once at the root and shared across all bindings, and each + /// binding grabs them here. + /// + /// Default: no-op. Bindings that support global parameters override this. + fn set_root_global_parameters( + &mut self, + _params: &[crate::openapi::discovery::GlobalParameter], + ) { + } + + /// Validate that all auth schemes referenced by the binding's spec + /// have a corresponding entry in the auth bindings. Returns `Ok(())` + /// if validation passes, or `Err(CliError::Validation(...))` listing + /// unregistered schemes. + /// + /// Default: no-op (passes). Concrete bindings override when they + /// can inspect their spec's security declarations. + fn validate_auth(&self) -> Result<(), CliError> { + Ok(()) + } + + /// Merge this binding's context into an existing context, or create + /// a new one if `existing` is `None`. + /// + /// When multiple bindings of the same protocol type are registered + /// on a `CliApp`, their contexts are merged so that custom command + /// handlers can access operations from any binding transparently. + /// + /// The default implementation delegates to [`binding_context`](Self::binding_context) + /// and ignores the existing context. + fn merge_binding_context( + &self, + matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + let _ = existing; + self.binding_context(matches) + } +} diff --git a/src/cli_args.rs b/src/cli_args.rs new file mode 100644 index 0000000..ee58e70 --- /dev/null +++ b/src/cli_args.rs @@ -0,0 +1,418 @@ +//! CLI argument helpers shared across protocol modules. +//! +//! Pure functions that operate on raw `&[String]` args or `clap::ArgMatches` +//! and have no protocol-specific dependencies. + +use std::io::{IsTerminal, Read}; + +use crate::error::CliError; + +/// True for `--version`, `-V`, or the bare `version` subcommand. +pub fn is_version_flag(arg: &str) -> bool { + matches!(arg, "--version" | "-V" | "version") +} + +/// Resolve the API base URL override from the `--base-url` flag and the +/// `{NAME}_BASE_URL` env var (flag wins). Validates the flag value for +/// dangerous characters; the env var is treated as trusted. +pub fn resolve_base_url_override( + matches: &clap::ArgMatches, + app_name: &str, +) -> Result, CliError> { + let base_url_flag = matches.get_one::("base-url").cloned(); + if let Some(ref url) = base_url_flag { + crate::output::reject_dangerous_chars(url, "--base-url")?; + } + let env_var_name = format!("{}_BASE_URL", app_name.to_uppercase().replace('-', "_")); + let base_url_env_var = std::env::var(env_var_name).ok(); + Ok(base_url_flag.or(base_url_env_var)) +} + +/// Resolve the consumer-supplied `User-Agent` suffix from the suffix +/// flag (`--user-agent-suffix` by default, or the configured +/// `userAgentSuffixFlag` name). The clap arg id is stable +/// (`"user-agent-suffix"`) regardless of the flag's long name, so this +/// lookup is name-independent. Returns the flag value if present, else +/// `None` — in which case [`crate::http::HttpConfig`] falls back to the +/// derived `_*` env var. Keeping the env fallback in `HttpConfig` +/// means the flag simply takes precedence when both are set. +pub fn resolve_user_agent_suffix_override(matches: &clap::ArgMatches) -> Option { + matches + .try_get_one::("user-agent-suffix") + .ok() + .flatten() + .cloned() +} + +/// True when raw args contain the `--schema` flag. +/// +/// `--schema` is the agent-facing machine-readable counterpart to `--help`: +/// wherever a user could type `--help` for prose, they can type `--schema` for +/// the same scope rendered as JSON. The flag is sniffed pre-clap because +/// clap would otherwise demand required args for the matched leaf +/// subcommand before our intercept runs. +pub fn wants_schema(args: &[String]) -> bool { + args.iter().any(|a| a == "--schema") +} + +/// True when raw args contain the `--spec` flag. +/// +/// `--spec` emits the effective OpenAPI spec (source + overlays + overrides +/// merged) to stdout. Sniffed pre-clap like `--schema` so that required-arg +/// validation does not block root-only flags. +pub fn wants_spec(args: &[String]) -> bool { + args.iter().any(|a| a == "--spec") +} + +/// True when raw args contain the `--spec-raw` flag. +/// +/// `--spec-raw` emits the byte-exact embedded source spec(s) to stdout, +/// before any overlay or override processing. Sniffed pre-clap like +/// `--schema`. +pub fn wants_spec_raw(args: &[String]) -> bool { + args.iter().any(|a| a == "--spec-raw") +} + +/// Extracts the subcommand path from raw args — non-flag tokens after the +/// binary name, skipping over global flag+value pairs wherever they appear. +/// +/// `["box", "users", "get", "--schema"]` → `["users", "get"]` +/// `["box", "--schema"]` → `[]` +/// `["box", "--base-url", "http://...", "users", "get"]` → `["users", "get"]` +/// `["box", "users", "get", "--user-id", "X", "--schema"]` → `["users", "get"]` +pub fn extract_subcommand_path(args: &[String]) -> Vec { + // Boolean (no-value) global flags. The token immediately after one of + // these is NOT consumed as a value — it may be a subcommand name. + const BOOL_FLAGS: &[&str] = &["--schema", "--spec", "--spec-raw", "--debug", "--version", "-V", "--help", "-h"]; + + let mut path = Vec::new(); + let mut iter = args.iter().skip(1).peekable(); // skip binary name + + while let Some(arg) = iter.next() { + if !arg.starts_with('-') { + path.push(arg.clone()); + } else if arg.contains('=') { + // --flag=value: value is embedded, nothing extra to consume. + } else if !BOOL_FLAGS.contains(&arg.as_str()) { + // Value-taking flag: skip the immediately following token if it + // doesn't look like a flag itself (it's the flag's value). + if iter.peek().map(|a| !a.starts_with('-')).unwrap_or(false) { + iter.next(); + } + } + // Boolean flags: consumed above; the next token is NOT their value. + } + + path +} + +/// True when the user invoked the bare `errors` subcommand. +/// +/// Matches only the exact two-argument form (` errors`) plus a +/// trailing `--format`/`-h`/`--help` global flag — keeping the surface +/// narrow so future user specs that define an `errors` group with +/// nested operations (e.g. `cli errors list`) are not silently +/// hijacked. The check happens before clap parses, so spec-driven +/// subcommands continue to dispatch normally. +/// +/// Format values (`json`, `yaml`, `table`, `csv`) are recognized only +/// immediately after `--format` (space-separated) or in the +/// `--format=` equals form. A bare `cli errors json` is NOT +/// intercepted — it falls through to clap so a user resource named +/// `json` remains reachable. +pub fn is_errors_subcommand(args: &[String]) -> bool { + if args.get(1).map(|s| s.as_str()) != Some("errors") { + return false; + } + // Allow only globally-recognized flags after the `errors` token so + // an `errors`-named API resource with positional subcommands like + // `errors list` is not hijacked. `--format`/`-h`/`--help` are the + // only flags this command honors (see `print_errors_table`); any + // other token defers to clap, which will return an "unrecognized + // subcommand" error or dispatch the user's resource as expected. + // + // Format values (json/yaml/table/csv) are accepted only when the + // previous token was `--format`; bare positional tokens like + // `cli errors json` fall through to clap. + let tail: Vec<&str> = args.iter().skip(2).map(|s| s.as_str()).collect(); + let mut i = 0; + while i < tail.len() { + let tok = tail[i]; + if tok == "--help" || tok == "-h" { + i += 1; + } else if tok == "--format" { + // Consume `--format` and its value (if present). + if let Some(next) = tail.get(i + 1) { + if is_format_value(next) { + i += 2; + } else { + // `--format` followed by an unrecognized value — + // not the errors subcommand. + return false; + } + } else { + // Trailing `--format` with no value — still recognized + // (print_errors falls back to the table format). + i += 1; + } + } else if let Some(rest) = tok.strip_prefix("--format=") { + if rest.is_empty() || is_format_value(rest) { + i += 1; + } else { + // `--format=banana` — unrecognized value; not the errors + // subcommand. + return false; + } + } else { + // Unknown positional or flag → user resource; defer to clap. + return false; + } + } + true +} + +/// Returns true for known `--format` values recognized by the `errors` +/// subcommand. +fn is_format_value(s: &str) -> bool { + s.eq_ignore_ascii_case("json") + || s.eq_ignore_ascii_case("yaml") + || s.eq_ignore_ascii_case("table") + || s.eq_ignore_ascii_case("csv") + || s.eq_ignore_ascii_case("raw") + || s.eq_ignore_ascii_case("jsonl") + || s.eq_ignore_ascii_case("ndjson") +} + +/// Read stdin to a string. Returns `Err` if stdin is a TTY or empty. +pub fn read_stdin_to_string() -> Result { + if std::io::stdin().is_terminal() { + return Err(CliError::Validation( + "stdin is a terminal; pipe data or redirect a file \ + (e.g. `cat data.json | cli cmd --json -`)" + .to_string(), + )); + } + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::Validation(format!("failed to read stdin: {e}")))?; + if buf.trim().is_empty() { + return Err(CliError::Validation( + "stdin was empty; `--json -` expects a JSON body to be piped on stdin" + .to_string(), + )); + } + Ok(buf) +} + +/// Resolve `--json` flag: `-` reads from stdin, else returns the literal. +pub fn resolve_body_json( + matched_args: &clap::ArgMatches, +) -> Result, CliError> { + let raw = matched_args + .try_get_one::("json") + .ok() + .flatten(); + match raw { + Some(s) if s == "-" => read_stdin_to_string().map(Some), + Some(s) => Ok(Some(s.clone())), + None => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_is_version_flag() { + assert!(is_version_flag("--version")); + assert!(is_version_flag("-V")); + assert!(is_version_flag("version")); + assert!(!is_version_flag("--ver")); + } + + #[test] + fn test_wants_schema_present() { + assert!(wants_schema(&args(&["cli", "--schema"]))); + assert!(wants_schema(&args(&["cli", "users", "--schema"]))); + assert!(wants_schema(&args(&["cli", "users", "get", "--user-id", "X", "--schema"]))); + } + + #[test] + fn test_wants_schema_absent() { + assert!(!wants_schema(&args(&["cli"]))); + assert!(!wants_schema(&args(&["cli", "users", "--help"]))); + assert!(!wants_schema(&args(&["cli", "--specification"]))); + } + + #[test] + fn test_wants_spec_present() { + assert!(wants_spec(&args(&["cli", "--spec"]))); + assert!(wants_spec(&args(&["cli", "users", "--spec"]))); + assert!(wants_spec(&args(&["cli", "users", "get", "--user-id", "X", "--spec"]))); + } + + #[test] + fn test_wants_spec_absent() { + assert!(!wants_spec(&args(&["cli"]))); + assert!(!wants_spec(&args(&["cli", "users", "--help"]))); + assert!(!wants_spec(&args(&["cli", "--specification"]))); + assert!(!wants_spec(&args(&["cli", "--spec-raw"]))); + } + + #[test] + fn test_wants_spec_raw_present() { + assert!(wants_spec_raw(&args(&["cli", "--spec-raw"]))); + assert!(wants_spec_raw(&args(&["cli", "users", "--spec-raw"]))); + assert!(wants_spec_raw(&args(&["cli", "users", "get", "--user-id", "X", "--spec-raw"]))); + } + + #[test] + fn test_wants_spec_raw_absent() { + assert!(!wants_spec_raw(&args(&["cli"]))); + assert!(!wants_spec_raw(&args(&["cli", "users", "--help"]))); + assert!(!wants_spec_raw(&args(&["cli", "--spec"]))); + } + + #[test] + fn test_extract_subcommand_path_root() { + assert_eq!( + extract_subcommand_path(&args(&["cli", "--schema"])), + Vec::::new(), + ); + } + + #[test] + fn test_extract_subcommand_path_one_segment() { + assert_eq!( + extract_subcommand_path(&args(&["cli", "users", "--schema"])), + vec!["users"], + ); + } + + #[test] + fn test_extract_subcommand_path_multi_segment() { + assert_eq!( + extract_subcommand_path(&args(&["cli", "users", "get", "--schema"])), + vec!["users", "get"], + ); + } + + #[test] + fn test_extract_subcommand_path_stops_at_first_flag() { + // Flags that appear after the subcommand tokens do not end up in the + // path. Value-taking flags consume their following argument too. + assert_eq!( + extract_subcommand_path(&args(&["cli", "users", "get", "--user-id", "X", "--schema"])), + vec!["users", "get"], + ); + } + + #[test] + fn test_extract_subcommand_path_skips_global_value_flag_before_subcommand() { + // A value-taking global flag (--base-url ) before the subcommand + // names must not swallow them. + assert_eq!( + extract_subcommand_path(&args(&["cli", "--base-url", "http://mock:9999", "users", "get"])), + vec!["users", "get"], + ); + } + + #[test] + fn test_extract_subcommand_path_skips_bool_flag_before_subcommand() { + // Boolean global flags (--debug) before the subcommand names must not + // consume the following subcommand token as their value. + assert_eq!( + extract_subcommand_path(&args(&["cli", "--debug", "users", "get"])), + vec!["users", "get"], + ); + } + + #[test] + fn test_extract_subcommand_path_embedded_value_flag() { + // --flag=value form: the `=` embeds the value; nothing extra consumed. + assert_eq!( + extract_subcommand_path(&args(&["cli", "--base-url=http://mock:9999", "users", "get"])), + vec!["users", "get"], + ); + } + + #[test] + fn test_is_errors_subcommand_positive() { + assert!(is_errors_subcommand(&args(&["cli", "errors"]))); + } + + #[test] + fn test_is_errors_subcommand_negative() { + assert!(!is_errors_subcommand(&args(&["cli", "get"]))); + assert!(!is_errors_subcommand(&args(&["cli"]))); + } + + #[test] + fn test_is_errors_subcommand_does_not_hijack_nested_resource() { + // If a user spec defines an `errors` resource with operations, + // `cli errors list` must defer to clap rather than print the + // exit codes table. + assert!(!is_errors_subcommand(&args(&["cli", "errors", "list"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "get", "123"]))); + } + + #[test] + fn test_is_errors_subcommand_allows_help_and_format_flags() { + assert!(is_errors_subcommand(&args(&["cli", "errors", "--help"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "-h"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "json"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format=json"]))); + } + + #[test] + fn test_is_errors_subcommand_rejects_unknown_flags() { + // Unknown flags after `errors` mean the user is targeting a + // spec-defined `errors` resource — defer to clap. + assert!(!is_errors_subcommand(&args(&["cli", "errors", "--json", "{}"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "--page-all"]))); + } + + #[test] + fn test_is_errors_subcommand_empty_args() { + assert!(!is_errors_subcommand(&args(&[]))); + } + + #[test] + fn test_is_errors_subcommand_bare_format_name_not_hijacked() { + // A bare `cli errors json` must NOT be intercepted — it should + // fall through to clap so a user resource named `json` is + // reachable. + assert!(!is_errors_subcommand(&args(&["cli", "errors", "json"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "yaml"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "table"]))); + assert!(!is_errors_subcommand(&args(&["cli", "errors", "csv"]))); + } + + #[test] + fn test_is_errors_subcommand_format_space_separated() { + // `--format json` (space-separated) must be recognized. + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "json"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "yaml"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "table"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format", "csv"]))); + } + + #[test] + fn test_is_errors_subcommand_format_equals() { + // `--format=json` (equals form) must be recognized. + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format=json"]))); + assert!(is_errors_subcommand(&args(&["cli", "errors", "--format=yaml"]))); + } + + #[test] + fn test_is_errors_subcommand_default_no_format() { + // Plain `cli errors` with no format flag is still recognized. + assert!(is_errors_subcommand(&args(&["cli", "errors"]))); + } +} diff --git a/src/completions.rs b/src/completions.rs new file mode 100644 index 0000000..84cdeb3 --- /dev/null +++ b/src/completions.rs @@ -0,0 +1,175 @@ +//! Shell completion generation. +//! +//! Shared infrastructure for emitting shell completion scripts. Sits above +//! both protocol paths (`openapi/` and `graphql/`) and has no +//! protocol-specific dependencies. + +use clap::Command; +use clap_complete::{generate, Shell}; + +/// Returns `true` when `args` contains `"completion"` as the first +/// positional token (i.e. the subcommand position). This allows early +/// interception before normal API dispatch — avoiding collision with an +/// API resource that might also be named `completion`. +/// +/// Skips `--flag value` pairs so `box --base-url completion files` is +/// not mistaken for a completion request (`completion` there is the +/// value of `--base-url`, not a subcommand). Boolean flags like +/// `--dry-run` are recognised and do NOT consume the next token. +pub fn wants_completion(args: &[String]) -> bool { + crate::early_intercept::first_positional_is(args, "completion") +} + +/// Generate a shell completion script for `cmd` and write it to `writer`. +/// +/// `bin_name` is the name the user types to invoke the CLI (e.g. `"box"`). +/// The caller is responsible for building a `Command` that mirrors the full +/// CLI surface (subcommands, flags, etc.) so the generated script is complete. +/// +/// Returns an IO error if writing fails. +pub fn generate_completion_to(shell: Shell, cmd: &mut Command, bin_name: &str, writer: &mut dyn std::io::Write) -> std::io::Result<()> { + let mut buf = Vec::new(); + generate(shell, cmd, bin_name, &mut buf); + writer.write_all(&buf) +} + +/// Generate a shell completion script for `cmd` and write it to stdout. +/// +/// Thin wrapper around [`generate_completion_to`] that targets `stdout`. +pub fn generate_completion(shell: Shell, cmd: &mut Command, bin_name: &str) -> std::io::Result<()> { + generate_completion_to(shell, cmd, bin_name, &mut std::io::stdout()) +} + +/// Parse a shell name string into a [`Shell`] enum variant. +/// +/// Matching is case-sensitive, consistent with `clap_complete::Shell`'s +/// `FromStr` implementation and the `value_parser` on +/// [`completion_command`]. Returns `None` for unrecognized values +/// (including case mismatches like `"BASH"`). +pub fn parse_shell(s: &str) -> Option { + match s { + "bash" => Some(Shell::Bash), + "zsh" => Some(Shell::Zsh), + "fish" => Some(Shell::Fish), + "powershell" => Some(Shell::PowerShell), + "elvish" => Some(Shell::Elvish), + _ => None, + } +} + +/// Build the `completion` subcommand definition. Registered at the root +/// of the command tree so ` completion ` works. +pub fn completion_command() -> Command { + Command::new("completion") + .about("Generate shell completion scripts") + .arg_required_else_help(true) + .after_help( + "EXAMPLES:\n \ + # bash\n \ + completion bash > /etc/bash_completion.d/\n \ + # zsh\n \ + completion zsh > \"${fpath[1]}/_\"\n \ + # fish\n \ + completion fish > ~/.config/fish/completions/.fish", + ) + .arg( + clap::Arg::new("shell") + .required(true) + .value_parser(["bash", "zsh", "fish", "powershell", "elvish"]) + .help("Target shell (bash, zsh, fish, powershell, elvish)"), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn wants_completion_detects_subcommand() { + assert!(wants_completion(&args(&["box", "completion", "bash"]))); + assert!(wants_completion(&args(&["box", "completion", "zsh"]))); + } + + #[test] + fn wants_completion_false_for_normal_commands() { + assert!(!wants_completion(&args(&["box", "files", "get"]))); + assert!(!wants_completion(&args(&["box", "--help"]))); + } + + #[test] + fn wants_completion_false_when_nested() { + assert!(!wants_completion(&args(&[ + "box", "files", "completion", "bash" + ]))); + } + + #[test] + fn wants_completion_false_when_flag_value() { + assert!(!wants_completion(&args(&[ + "box", + "--base-url", + "completion", + "files", + ]))); + } + + #[test] + fn wants_completion_true_after_eq_flag() { + assert!(wants_completion(&args(&[ + "box", + "--base-url=http://localhost", + "completion", + "bash", + ]))); + } + + #[test] + fn wants_completion_with_boolean_flag() { + // --dry-run is a boolean flag (SetTrue) and must NOT consume the + // next token; "completion" is the subcommand, not the flag's value. + assert!(wants_completion(&args(&[ + "box", + "--dry-run", + "completion", + "bash", + ]))); + } + + #[test] + fn wants_completion_with_multiple_boolean_flags() { + assert!(wants_completion(&args(&[ + "box", + "--dry-run", + "--no-retry", + "completion", + "zsh", + ]))); + } + + #[test] + fn parse_shell_valid() { + assert_eq!(parse_shell("bash"), Some(Shell::Bash)); + assert_eq!(parse_shell("zsh"), Some(Shell::Zsh)); + assert_eq!(parse_shell("fish"), Some(Shell::Fish)); + assert_eq!(parse_shell("powershell"), Some(Shell::PowerShell)); + assert_eq!(parse_shell("elvish"), Some(Shell::Elvish)); + } + + #[test] + fn parse_shell_rejects_uppercase() { + // parse_shell must be case-sensitive, matching clap's value_parser. + assert_eq!(parse_shell("BASH"), None); + assert_eq!(parse_shell("Zsh"), None); + assert_eq!(parse_shell("FISH"), None); + } + + #[test] + fn parse_shell_invalid() { + assert_eq!(parse_shell("nushell"), None); + assert_eq!(parse_shell(""), None); + } +} diff --git a/src/custom_commands.rs b/src/custom_commands.rs new file mode 100644 index 0000000..40272a9 --- /dev/null +++ b/src/custom_commands.rs @@ -0,0 +1,400 @@ +//! Helpers for grafting custom CLI subcommands onto a spec-derived +//! command tree and walking parsed `ArgMatches` to dispatch them. +//! +//! Used by `app::CliApp::command()` / `command_under()` at the root +//! level. The free functions `graft_subcommand` and +//! `walk_matches_to_custom` are the public (crate-internal) API. + +/// Graft a custom `clap::Command` into an existing command tree under +/// `parent_path`. The leaf name is `cmd.get_name()`. +/// +/// Behavior: +/// - Walks down `parent_path` using `mut_subcommand`, recursively grafting. +/// - At any level where the named parent doesn't exist, creates it as a +/// bare subcommand so the path is reachable. +/// - At the leaf level, if a subcommand with the same name already exists +/// it is replaced by `cmd` (custom-wins on leaf collision). +pub fn graft_subcommand( + parent: clap::Command, + parent_path: &[String], + cmd: clap::Command, +) -> clap::Command { + if parent_path.is_empty() { + let leaf_name = cmd.get_name().to_string(); + if parent.find_subcommand(&leaf_name).is_some() { + parent.mut_subcommand(leaf_name, move |_existing| cmd) + } else { + parent.subcommand(cmd) + } + } else { + let head = parent_path[0].clone(); + let rest: Vec = parent_path[1..].to_vec(); + if parent.find_subcommand(&head).is_some() { + parent.mut_subcommand(head, move |sub| graft_subcommand(sub, &rest, cmd)) + } else { + let new_parent = clap::Command::new(head) + .subcommand_required(true) + .arg_required_else_help(true); + let new_parent = graft_subcommand(new_parent, &rest, cmd); + parent.subcommand(new_parent) + } + } +} + +/// Walk a parsed `ArgMatches` tree along `parent_path` and return the leaf +/// matches if the final subcommand equals `leaf_name`. Returns `None` if +/// any segment along the path doesn't match. +pub fn walk_matches_to_custom<'a>( + matches: &'a clap::ArgMatches, + parent_path: &[String], + leaf_name: &str, +) -> Option<&'a clap::ArgMatches> { + let mut current = matches; + for seg in parent_path { + let (name, sub) = current.subcommand()?; + if name != seg { + return None; + } + current = sub; + } + let (name, sub) = current.subcommand()?; + if name == leaf_name { + Some(sub) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::CliError; + + // ── Registry (test-only) ──────────────────────────────────────── + // + // `CustomCommandRegistry` was the old per-binding custom command + // system. Root `CliApp::command()` replaced it, but the struct is + // still useful for testing `graft_subcommand` / `walk_matches_to_custom`. + + type HandlerFn = fn(&clap::ArgMatches, &C) -> Result<(), CliError>; + type Entry = (Vec, clap::Command, HandlerFn); + + struct CustomCommandRegistry { + entries: Vec>, + } + + impl CustomCommandRegistry { + fn new() -> Self { + Self { entries: Vec::new() } + } + + fn register(&mut self, cmd: clap::Command, handler: HandlerFn) { + self.register_under::<&str>(&[], cmd, handler); + } + + fn register_under>( + &mut self, + path: &[S], + cmd: clap::Command, + handler: HandlerFn, + ) { + let owned: Vec = path.iter().map(|s| s.as_ref().to_string()).collect(); + self.entries.push((owned, cmd, handler)); + } + + fn graft_into(&self, mut cli: clap::Command) -> clap::Command { + for (path, cmd, _) in &self.entries { + cli = graft_subcommand(cli, path, cmd.clone()); + } + cli + } + + fn dispatch( + &self, + matches: &clap::ArgMatches, + ctx: &C, + ) -> Option> { + for (path, cmd, handler) in &self.entries { + if let Some(target) = walk_matches_to_custom(matches, path, cmd.get_name()) { + return Some(handler(target, ctx)); + } + } + None + } + + fn len(&self) -> usize { + self.entries.len() + } + + fn entries(&self) -> &[Entry] { + &self.entries + } + } + + struct DummyCtx; + + fn dummy_handler(_m: &clap::ArgMatches, _c: &DummyCtx) -> Result<(), CliError> { + Ok(()) + } + + #[test] + fn graft_top_level_adds_command() { + let cli = clap::Command::new("root").subcommand(clap::Command::new("existing")); + let custom = clap::Command::new("custom"); + let grafted = graft_subcommand(cli, &[], custom); + assert!(grafted.find_subcommand("existing").is_some()); + assert!(grafted.find_subcommand("custom").is_some()); + } + + #[test] + fn graft_top_level_collision_replaces_leaf() { + let cli = clap::Command::new("root") + .subcommand(clap::Command::new("dup").about("from spec")); + let custom = clap::Command::new("dup").about("from custom"); + let grafted = graft_subcommand(cli, &[], custom); + let dup = grafted.find_subcommand("dup").unwrap(); + assert_eq!(dup.get_about().map(|s| s.to_string()).as_deref(), Some("from custom")); + } + + #[test] + fn graft_into_existing_parent_keeps_siblings() { + let webhooks = clap::Command::new("webhooks") + .subcommand(clap::Command::new("list")) + .subcommand(clap::Command::new("create")); + let cli = clap::Command::new("root").subcommand(webhooks); + + let verify = clap::Command::new("verify").about("custom"); + let grafted = graft_subcommand(cli, &["webhooks".to_string()], verify); + + let webhooks = grafted.find_subcommand("webhooks").unwrap(); + assert!(webhooks.find_subcommand("list").is_some()); + assert!(webhooks.find_subcommand("create").is_some()); + assert!(webhooks.find_subcommand("verify").is_some()); + } + + #[test] + fn graft_leaf_collision_under_parent_replaces() { + let webhooks = clap::Command::new("webhooks") + .subcommand(clap::Command::new("list").about("spec")); + let cli = clap::Command::new("root").subcommand(webhooks); + let custom_list = clap::Command::new("list").about("custom"); + let grafted = graft_subcommand(cli, &["webhooks".to_string()], custom_list); + let leaf = grafted + .find_subcommand("webhooks") + .unwrap() + .find_subcommand("list") + .unwrap(); + assert_eq!(leaf.get_about().map(|s| s.to_string()).as_deref(), Some("custom")); + } + + #[test] + fn graft_creates_missing_intermediate_parent() { + let cli = clap::Command::new("root"); + let leaf = clap::Command::new("verify"); + let grafted = graft_subcommand(cli, &["new-parent".to_string()], leaf); + let parent = grafted.find_subcommand("new-parent").unwrap(); + assert!(parent.find_subcommand("verify").is_some()); + } + + #[test] + fn walk_matches_finds_leaf() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("webhooks").subcommand(clap::Command::new("verify"))); + let matches = cmd.get_matches_from(vec!["root", "webhooks", "verify"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_some()); + } + + #[test] + fn walk_matches_misses_when_path_diverges() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("webhooks").subcommand(clap::Command::new("list"))); + let matches = cmd.get_matches_from(vec!["root", "webhooks", "list"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_none()); + } + + #[test] + fn walk_matches_misses_when_parent_diverges() { + let cmd = clap::Command::new("root") + .subcommand(clap::Command::new("other").subcommand(clap::Command::new("verify"))); + let matches = cmd.get_matches_from(vec!["root", "other", "verify"]); + let result = walk_matches_to_custom(&matches, &["webhooks".to_string()], "verify"); + assert!(result.is_none()); + } + + #[test] + fn registry_registers_top_level_command() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register(clap::Command::new("custom"), dummy_handler); + assert_eq!(reg.len(), 1); + assert!(reg.entries()[0].0.is_empty()); + assert_eq!(reg.entries()[0].1.get_name(), "custom"); + } + + #[test] + fn registry_registers_under_path() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + assert_eq!(reg.len(), 1); + assert_eq!(reg.entries()[0].0, vec!["webhooks".to_string()]); + assert_eq!(reg.entries()[0].1.get_name(), "verify"); + } + + #[test] + fn registry_graft_into_grafts_all_entries() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register(clap::Command::new("alpha"), dummy_handler); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + + let cli = clap::Command::new("root"); + let grafted = reg.graft_into(cli); + + assert!(grafted.find_subcommand("alpha").is_some()); + let webhooks = grafted.find_subcommand("webhooks").unwrap(); + assert!(webhooks.find_subcommand("verify").is_some()); + } + + #[test] + fn registry_dispatch_invokes_matching_handler() { + use std::cell::Cell; + // Use thread-local state so the fn pointer (which can't capture) + // can record that it ran. + thread_local! { + static CALLED: Cell = const { Cell::new(false) }; + } + fn handler(_m: &clap::ArgMatches, _c: &DummyCtx) -> Result<(), CliError> { + CALLED.with(|c| c.set(true)); + Ok(()) + } + + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), handler); + + let cli = clap::Command::new("root"); + let cli = reg.graft_into(cli); + let matches = cli.get_matches_from(vec!["root", "webhooks", "verify"]); + + let result = reg.dispatch(&matches, &DummyCtx); + assert!(result.is_some()); + assert!(result.unwrap().is_ok()); + assert!(CALLED.with(|c| c.get())); + } + + #[test] + fn registry_dispatch_returns_none_when_no_custom_invoked() { + let mut reg: CustomCommandRegistry = CustomCommandRegistry::new(); + reg.register_under(&["webhooks"], clap::Command::new("verify"), dummy_handler); + + // Build a tree that has both a custom and a non-custom path. + let cli = clap::Command::new("root") + .subcommand(clap::Command::new("other").subcommand(clap::Command::new("thing"))); + let cli = reg.graft_into(cli); + let matches = cli.get_matches_from(vec!["root", "other", "thing"]); + + let result = reg.dispatch(&matches, &DummyCtx); + assert!(result.is_none()); + } + + // ── Typed command tests ───────────────────────────────────────── + + use clap::{Args, FromArgMatches}; + + #[derive(clap::Args, Debug, PartialEq)] + struct AdoptArgs { + #[arg(long)] + name: String, + #[arg(long)] + tag: Option, + } + + #[test] + fn typed_command_augments_args_onto_command() { + let base = clap::Command::new("adopt").about("Adopt a pet"); + let augmented = AdoptArgs::augment_args(base); + // The augmented command should have --name and --tag arguments. + let args: Vec<_> = augmented.get_arguments().map(|a| a.get_id().as_str().to_string()).collect(); + assert!(args.contains(&"name".to_string()), "missing --name: {args:?}"); + assert!(args.contains(&"tag".to_string()), "missing --tag: {args:?}"); + } + + #[test] + fn typed_command_parses_args_from_matches() { + let cmd = AdoptArgs::augment_args(clap::Command::new("adopt")); + let matches = cmd.get_matches_from(vec!["adopt", "--name", "Fido", "--tag", "dog"]); + let parsed = AdoptArgs::from_arg_matches(&matches).unwrap(); + assert_eq!(parsed, AdoptArgs { name: "Fido".into(), tag: Some("dog".into()) }); + } + + #[test] + fn typed_command_parses_optional_absent() { + let cmd = AdoptArgs::augment_args(clap::Command::new("adopt")); + let matches = cmd.get_matches_from(vec!["adopt", "--name", "Buddy"]); + let parsed = AdoptArgs::from_arg_matches(&matches).unwrap(); + assert_eq!(parsed, AdoptArgs { name: "Buddy".into(), tag: None }); + } + + #[test] + fn typed_command_erased_dispatch_round_trip() { + use std::cell::Cell; + thread_local! { + static SEEN_NAME: Cell> = const { Cell::new(None) }; + } + + // Build the erased handler the same way CliApp::command_typed_with does: + // handler is fn(A, &C) and the closure does downcast + parse internally. + fn my_handler(args: AdoptArgs, _ctx: &DummyCtx) -> Result<(), CliError> { + SEEN_NAME.with(|c| c.set(Some(args.name))); + Ok(()) + } + + let handler_fn: fn(AdoptArgs, &DummyCtx) -> Result<(), CliError> = my_handler; + let erased: crate::app::CliCommandHandler = Box::new(move |matches, ctx| { + let args = AdoptArgs::from_arg_matches(matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + let ctx = ctx.downcast_ref::().ok_or_else(|| { + CliError::Validation("binding context type mismatch".into()) + })?; + handler_fn(args, ctx) + }); + + let cmd = AdoptArgs::augment_args(clap::Command::new("adopt")); + let cli = graft_subcommand(clap::Command::new("root"), &[], cmd.clone()); + let matches = cli.get_matches_from(vec!["root", "adopt", "--name", "Rex"]); + let target = walk_matches_to_custom(&matches, &[], "adopt").unwrap(); + + erased(target, &DummyCtx as &dyn std::any::Any).unwrap(); + assert_eq!(SEEN_NAME.with(|c| c.take()), Some("Rex".to_string())); + } + + #[test] + fn typed_command_context_mismatch_returns_error() { + fn my_handler(_args: AdoptArgs, _ctx: &DummyCtx) -> Result<(), CliError> { + Ok(()) + } + + let handler_fn: fn(AdoptArgs, &DummyCtx) -> Result<(), CliError> = my_handler; + let erased: crate::app::CliCommandHandler = Box::new(move |matches, ctx| { + let args = AdoptArgs::from_arg_matches(matches) + .map_err(|e| CliError::Validation(e.to_string()))?; + let ctx = ctx.downcast_ref::().ok_or_else(|| { + CliError::Validation("binding context type mismatch".into()) + })?; + handler_fn(args, ctx) + }); + + let cmd = AdoptArgs::augment_args(clap::Command::new("adopt")); + let cli = graft_subcommand(clap::Command::new("root"), &[], cmd.clone()); + let matches = cli.get_matches_from(vec!["root", "adopt", "--name", "Rex"]); + let target = walk_matches_to_custom(&matches, &[], "adopt").unwrap(); + + // Pass wrong context type — should get a Validation error. + let result = erased(target, &42u32 as &dyn std::any::Any); + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("mismatch"), "unexpected: {msg}"), + other => panic!("expected Validation error, got: {other:?}"), + } + } +} diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..8c972e2 --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,1142 @@ +//! Debug HTTP dump and rich error display. +//! +//! When `--debug` is passed on the CLI, the executor calls into this module +//! to print a curl-style HTTP request/response dump to stderr. Auth headers +//! are redacted to avoid leaking secrets into logs, terminal scrollback, or +//! screenshots. Request bodies are also scanned for sensitive keys +//! (`password`, `client_secret`, etc.) and their values replaced with +//! `[REDACTED]`. +//! +//! The rich error display reformats API error responses into a more readable +//! layout with status badges, timing, and the response body indented for +//! quick scanning. +//! +//! # Output format +//! +//! This module uses curl-style line prefixes: +//! - `>` -- outgoing request lines (method+URL, request headers, request body) +//! - `<` -- incoming response lines (response headers, response body) +//! - `*` -- connection metadata (HTTP status with timing) + +use reqwest::header::HeaderMap; + +use percent_encoding::percent_decode_str; + +use crate::output::colorize; + +/// Headers whose values are always fully redacted in debug output. +const REDACTED_HEADERS: &[&str] = &[ + "authorization", + "www-authenticate", + "x-api-key", + "api-key", + "apikey", + "x-api-token", + "x-auth-token", + "auth-token", + "cookie", + "set-cookie", + "proxy-authorization", + "proxy-authenticate", + "x-amz-security-token", + "x-csrf-token", + "x-xsrf-token", + "x-session-token", + "x-access-token", +]; + +// Note: The previous `BODY_SENSITIVE_KEYS` exact-match array has been replaced +// by the hybrid `is_sensitive_body_key` function below, which combines exact +// matches for short stems with substring matches for compound stems. This +// catches `new_password`, `id_token`, `private_key`, etc. without a growing +// denylist. See the function documentation for the rationale. + +/// Maximum response body bytes to display in debug output (128 KiB). +const MAX_BODY_DISPLAY: usize = 128 * 1024; + +/// Print a debug dump of the outgoing HTTP request to stderr. +/// +/// Redacts sensitive headers (including any spec-declared custom auth header +/// names passed via `extra_sensitive_headers`), sensitive body keys, and +/// sensitive query parameters in the URL. Called just before `.send()` in +/// the executor. +/// +/// Uses curl-style `>` prefix for request lines. +pub(crate) fn dump_request( + method: &str, + url: &str, + headers: &HeaderMap, + body: Option<&str>, + extra_sensitive_headers: &[&str], + extra_sensitive_query_params: &[&str], +) { + let safe_url = redact_url_query(url, extra_sensitive_query_params); + eprintln!(); + eprintln!( + "{} {} {}", + colorize(">", "36"), + colorize(method, "1"), + safe_url, + ); + + print_headers(">", headers, extra_sensitive_headers); + + if let Some(b) = body { + if !b.is_empty() { + eprintln!(">"); + let redacted = redact_body(b); + print_body_preview(">", "Request body", &redacted); + } + } + eprintln!(">"); +} + +/// Print a debug dump of a GraphQL request to stderr. +/// +/// Unlike the generic [`dump_request`], this function understands the GraphQL +/// body structure and formats it for readability: +/// - `query` is shown as raw multi-line GraphQL text (not a JSON-escaped string) +/// - `variables` are pretty-printed JSON with sensitive key redaction +/// +/// Uses curl-style `>` prefix for request lines. GraphQL always POSTs so the +/// method is hardcoded; the URL is shown and sanitized. +pub(crate) fn dump_graphql_request( + url: &str, + headers: &HeaderMap, + query: &str, + variables: &serde_json::Value, + extra_sensitive_headers: &[&str], +) { + // GraphQL requests never carry auth in query params. + let safe_url = redact_url_query(url, &[]); + eprintln!(); + eprintln!( + "{} {} {}", + colorize(">", "36"), + colorize("POST", "1"), + safe_url, + ); + + print_headers(">", headers, extra_sensitive_headers); + + // Query: show as raw GraphQL text, not a JSON-escaped string. + let query_trimmed = query.trim(); + if !query_trimmed.is_empty() { + eprintln!(">"); + eprintln!("> {}", colorize("GraphQL query:", "90")); + for line in query_trimmed.lines() { + eprintln!("> {line}"); + } + } + + // Variables: pretty-print with sensitive key redaction. + if let serde_json::Value::Object(map) = variables { + if !map.is_empty() { + let mut vars = variables.clone(); + redact_json_value(&mut vars); + if let Ok(pretty) = serde_json::to_string_pretty(&vars) { + eprintln!(">"); + print_body_preview(">", "GraphQL variables", &pretty); + } + } + } + eprintln!(">"); +} + +/// Print a debug dump of the HTTP response to stderr. +/// +/// Includes status, timing, headers, and a truncated body preview. +/// Uses curl-style `*` prefix for status/timing and `<` for response lines. +pub(crate) fn dump_response( + status: u16, + latency_ms: u64, + headers: &HeaderMap, + body: &str, + extra_sensitive_headers: &[&str], +) { + let status_color = if status < 300 { + "32" // green + } else if status < 400 { + "33" // yellow + } else { + "31" // red + }; + + eprintln!( + "* {}", + colorize(&format!("HTTP {status} ({latency_ms}ms)"), status_color), + ); + + print_headers("<", headers, extra_sensitive_headers); + + if !body.is_empty() { + eprintln!("<"); + let redacted = redact_body(body); + print_body_preview("<", "Response body", &redacted); + } + eprintln!(); +} + +/// Print a rich error display to stderr when `--debug` is active. +/// +/// Augments the standard JSON error output with a formatted block showing +/// the HTTP status, error body (pretty-printed if JSON), and timing. +/// Uses curl-style `*` prefix for status/timing and `<` for response lines. +pub(crate) fn dump_error_response( + status: u16, + latency_ms: u64, + headers: &HeaderMap, + body: &str, + extra_sensitive_headers: &[&str], +) { + eprintln!( + "* {}", + colorize(&format!("HTTP {status} ({latency_ms}ms)"), "31"), + ); + + print_headers("<", headers, extra_sensitive_headers); + + if !body.is_empty() { + eprintln!("<"); + let redacted = redact_body(body); + print_body_preview("<", "Error body", &redacted); + } + eprintln!(); +} + +/// Print a streaming response note to stderr when the body cannot be buffered. +/// +/// Used for binary downloads, SSE, and `x-fern-streaming` responses where the +/// body is consumed by the caller and cannot be dumped. Emits: +/// - a `*` line with the HTTP status (no latency) +/// - a `<` line per response header (sensitive headers are redacted) +/// - a final `<` line indicating the body is not buffered +pub(crate) fn dump_streaming_note(status: u16, headers: &HeaderMap, extra_sensitive_headers: &[&str]) { + eprintln!( + "* {}", + colorize(&format!("HTTP {status}"), "36"), + ); + + print_headers("<", headers, extra_sensitive_headers); + + eprintln!("< [streaming response — body not buffered]"); +} + +/// Status + headers for a response whose body this layer never reads. +/// +/// The SDK-executor path (custom commands) hands the response straight to the +/// generated SDK crate, which deserializes it — so buffering the body here to +/// print it would either consume it or double the memory for every call. The +/// request side, which is what you actually need when diagnosing "what did the +/// CLI send?", is dumped in full. +pub(crate) fn dump_response_headers_only( + status: u16, + latency_ms: u64, + headers: &HeaderMap, + extra_sensitive_headers: &[&str], +) { + eprintln!( + "* {} in {}", + colorize(&format!("HTTP {status}"), "36"), + colorize(&format!("{latency_ms}ms"), "90"), + ); + print_headers("<", headers, extra_sensitive_headers); + eprintln!("< [body consumed by the SDK client — not buffered for --debug]"); +} + +/// Returns true if the header name is sensitive and should be redacted. +/// +/// Checks the static denylist plus any spec-derived custom auth header names +/// (e.g., `X-Custom-Auth` from an `apiKey in: header` security scheme). +pub(crate) fn is_sensitive_header(name: &str, extra_sensitive: &[&str]) -> bool { + if REDACTED_HEADERS.iter().any(|&h| h.eq_ignore_ascii_case(name)) { + return true; + } + if extra_sensitive.iter().any(|&h| h.eq_ignore_ascii_case(name)) { + return true; + } + name_looks_like_credential(name) +} + +/// Substrings that mark a header name as carrying a credential. +/// +/// [`REDACTED_HEADERS`] is an exact-match list of well-known names, and +/// spec-declared `apiKey`-in-header schemes arrive via `extra_sensitive` — but +/// neither covers the common case of a spec that models its credential as a +/// plain header *parameter* with a vendor-specific name and no +/// `securitySchemes` block at all. ElevenLabs' `xi-api-key` is exactly that: an +/// explicit header parameter on 333 operations, invisible to both other +/// mechanisms, and printed in full by `--debug` and `--dry-run` as a result. +/// +/// Matching on substrings risks redacting something merely useful rather than +/// secret; that trade is deliberate, since the cost is one unreadable value in a +/// diagnostic dump versus a leaked key in a pasted bug report. `idempotency-key` +/// is specifically *not* matched (`key` alone is not a pattern) because it is not +/// a credential and is genuinely useful when debugging retries. +const CREDENTIAL_NAME_PATTERNS: &[&str] = &[ + "api-key", + "apikey", + "api_key", + "token", + "secret", + "password", + "passwd", + "credential", +]; + +/// Header-name prefixes that never carry a credential, checked before the +/// pattern match below. +/// +/// CORS response headers are the case that matters: +/// `access-control-allow-credentials` is a routine browser-permissions flag +/// (its value is literally `true`), but it contains the substring `credential` +/// and so was being redacted — hiding something harmless and making `--debug` +/// less useful for no gain. +const NEVER_REDACTED_PREFIXES: &[&str] = &["access-control-"]; + +fn name_looks_like_credential(name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + if NEVER_REDACTED_PREFIXES + .iter() + .any(|prefix| lowered.starts_with(prefix)) + { + return false; + } + CREDENTIAL_NAME_PATTERNS + .iter() + .any(|pattern| lowered.contains(pattern)) +} + +/// Emit each header to stderr, prefixed with `line_prefix` (typically `">"` or +/// `"<"`), with sensitive values replaced by `[REDACTED]`. +fn print_headers(line_prefix: &str, headers: &HeaderMap, extra_sensitive: &[&str]) { + for (name, value) in headers.iter() { + let name_str = name.as_str(); + let display_value = if is_sensitive_header(name_str, extra_sensitive) { + "[REDACTED]".to_string() + } else { + crate::output::sanitize_for_terminal(value.to_str().unwrap_or("")) + }; + eprintln!("{line_prefix} {}: {display_value}", colorize(name_str, "90")); + } +} + +/// Redact sensitive keys in a body string (request or response). +/// +/// - JSON bodies: recursively walks the value tree and replaces any value +/// whose key matches [`is_sensitive_body_key`] (case-insensitive, hybrid +/// exact + substring strategy) with `"[REDACTED]"`. +/// - Form-encoded bodies (`key=value&...`): redacts values for matching keys. +/// - Other formats: returned unchanged. +pub(crate) fn redact_body(body: &str) -> String { + // Try JSON first. + if let Ok(mut parsed) = serde_json::from_str::(body) { + if parsed.is_object() || parsed.is_array() { + redact_json_value(&mut parsed); + return serde_json::to_string(&parsed).unwrap_or_else(|_| body.to_string()); + } + // Bare scalars (null, bool, number, string) — nothing to redact. + return body.to_string(); + } + + // Try form-encoded (`key=value&key2=value2`). + if looks_like_form_encoded(body) { + return redact_form_encoded(body); + } + + // Unknown format — return as-is. + body.to_string() +} + +/// Recursively walk a JSON value and replace sensitive keys with `[REDACTED]`. +fn redact_json_value(value: &mut serde_json::Value) { + match value { + serde_json::Value::Object(map) => { + for (key, val) in map.iter_mut() { + if is_sensitive_body_key(key) { + *val = serde_json::Value::String("[REDACTED]".to_string()); + } else { + redact_json_value(val); + } + } + } + serde_json::Value::Array(arr) => { + for item in arr.iter_mut() { + redact_json_value(item); + } + } + _ => {} + } +} + +/// Case-insensitive check for sensitive body keys using a hybrid strategy. +/// +/// **Exact matches** catch short stems that would over-match as substrings +/// (e.g., `"token"` alone should match, but we don't want `"tokenizer"` to +/// match — the substring list uses the longer `"token"` stem only in compound +/// key names like `access_token`). +/// +/// **Substring matches** catch compound key names like `new_password`, +/// `id_token`, `private_key`, `password_confirmation`, etc. without needing +/// to enumerate every variant. +/// +/// Note: keys like `password_hint` and `passwordless` will be flagged because +/// `"password"` is a substring. This is a defensible over-redaction — fields +/// with `password` in their name may carry secret-adjacent content, and +/// over-redacting in debug output is far safer than under-redacting. +fn is_sensitive_body_key(key: &str) -> bool { + let k = key.to_ascii_lowercase(); + + // Exact match for short stems that would over-match as substrings. + const EXACT: &[&str] = &["token", "secret", "key", "session", "pwd", "jwt", "bearer", "cookie"]; + if EXACT.iter().any(|&e| e == k) { + return true; + } + + // Substring match for compound stems (catches new_password, id_token, etc.). + // + // `"token"`, `"secret"`, `"key"`, and `"session"` are intentionally absent + // as bare substrings — they live in the EXACT list above. Using them as + // substrings would over-match innocent keys like `tokenizer`, `secretary`, + // `monkey`, and `session_count`. Compound key names use delimiter-bounded + // patterns (`_token`, `token_`, `_secret`, `secret_`, `_key`, `key_`, + // `_session`, `session_`) to catch `access_token`, `client_secret`, + // `api_key`, `session_id`, etc. Hyphenated variants mirror the same pattern. + const STEMS: &[&str] = &[ + "password", + "passwd", + "_secret", + "secret_", + "-secret", + "secret-", + "_token", + "token_", + "-token", + "token-", + "_key", + "key_", + "-key", + "key-", + "_session", + "session_", + "-session", + "session-", + "apikey", + "apisecret", + "api_key", + "api-key", + "private_key", + "authorization", + "credential", + ]; + STEMS.iter().any(|s| k.contains(s)) +} + +/// Returns true if a URL query parameter name is sensitive and should be +/// redacted. Combines the body-key heuristic with extra spec-derived names +/// (e.g. from `apiKey in: query` security schemes). +fn is_sensitive_query_param(name: &str, extra: &[&str]) -> bool { + is_sensitive_body_key(name) || extra.iter().any(|e| e.eq_ignore_ascii_case(name)) +} + +/// Redact embedded credentials and sensitive query parameters from a URL. +/// +/// - Replaces `user:pass@host` credentials with `[REDACTED]@host`. +/// - Inspects each query pair and replaces the values of sensitive keys with +/// `[REDACTED]`. The `extra_sensitive_params` slice carries spec-derived +/// param names (e.g. from `apiKey in: query` security schemes) that +/// supplement the built-in heuristic. +/// +/// Returns the URL unchanged if it isn't parseable. +pub(crate) fn redact_url_query(raw_url: &str, extra_sensitive_params: &[&str]) -> String { + let Ok(mut url) = reqwest::Url::parse(raw_url) else { + return raw_url.to_string(); + }; + + // Redact embedded HTTP Basic credentials (`https://user:pass@host`). + if url.password().is_some() || url.username() != "" { + // Replacing credentials in a `Url` requires set_username/set_password, + // which only succeed for non-cannot-be-a-base URLs — swallow errors. + let _ = url.set_username("[REDACTED]"); + let _ = url.set_password(None); + } + + if url.query().is_none() { + return url.to_string(); + } + let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect(); + let rebuilt: String = pairs + .iter() + .map(|(k, v)| { + let val = if is_sensitive_query_param(k, extra_sensitive_params) { + "[REDACTED]".to_string() + } else { + v.clone() + }; + format!("{}={}", k, val) + }) + .collect::>() + .join("&"); + url.set_query(if rebuilt.is_empty() { None } else { Some(&rebuilt) }); + url.to_string() +} + +/// Heuristic: does the string look like `application/x-www-form-urlencoded`? +/// Checks for `key=value` pairs separated by `&`. +fn looks_like_form_encoded(body: &str) -> bool { + // Must contain at least one `=` and no newlines (to distinguish from + // plain text or multi-line payloads). + !body.contains('\n') && body.contains('=') && body.split('&').all(|pair| { + pair.contains('=') || pair.is_empty() + }) +} + +/// Redact values for sensitive keys in a form-encoded string. +fn redact_form_encoded(body: &str) -> String { + body.split('&') + .map(|pair| { + if let Some((key, _value)) = pair.split_once('=') { + // Decode the key for matching (form keys may be percent-encoded). + let decoded_key = percent_decode_str(key).decode_utf8_lossy(); + if is_sensitive_body_key(&decoded_key) { + format!("{key}=[REDACTED]") + } else { + pair.to_string() + } + } else { + pair.to_string() + } + }) + .collect::>() + .join("&") +} + +/// Pretty-print a body preview to stderr, truncating if needed. +/// +/// `line_prefix` is the curl-style prefix character: `">"` for request bodies, +/// `"<"` for response and error bodies. Each emitted line is prefixed with +/// `{line_prefix} `. +/// +/// Attempts JSON pretty-printing for objects/arrays; falls back to raw text +/// for bare scalars (`null`, numbers, bools, bare strings) and non-JSON. +fn print_body_preview(line_prefix: &str, label: &str, body: &str) { + let display = if body.len() > MAX_BODY_DISPLAY { + let mut end = MAX_BODY_DISPLAY; + while !body.is_char_boundary(end) { + end -= 1; + } + &body[..end] + } else { + body + }; + + let formatted = if let Ok(parsed) = serde_json::from_str::(display) { + if parsed.is_object() || parsed.is_array() { + serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| display.to_string()) + } else { + // Bare JSON scalars — show raw text, not misleading pretty-print. + display.to_string() + } + } else { + display.to_string() + }; + + eprintln!("{line_prefix} {}:", colorize(label, "90")); + for line in formatted.lines() { + eprintln!("{line_prefix} {line}"); + } + if body.len() > MAX_BODY_DISPLAY { + eprintln!( + "{line_prefix} ... ({} bytes total, truncated)", + body.len() + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // -- Header redaction ----------------------------------------- + + #[test] + fn sensitive_headers_detected() { + let no_extra: &[&str] = &[]; + assert!(is_sensitive_header("Authorization", no_extra)); + assert!(is_sensitive_header("authorization", no_extra)); + assert!(is_sensitive_header("X-Api-Key", no_extra)); + assert!(is_sensitive_header("x-api-key", no_extra)); + assert!(is_sensitive_header("X-Auth-Token", no_extra)); + assert!(is_sensitive_header("Cookie", no_extra)); + assert!(is_sensitive_header("Proxy-Authorization", no_extra)); + // Newly added static entries. + assert!(is_sensitive_header("X-Amz-Security-Token", no_extra)); + assert!(is_sensitive_header("x-csrf-token", no_extra)); + assert!(is_sensitive_header("X-Session-Token", no_extra)); + assert!(is_sensitive_header("Set-Cookie", no_extra)); + assert!(is_sensitive_header("set-cookie", no_extra)); + } + + #[test] + fn non_sensitive_headers_pass_through() { + let no_extra: &[&str] = &[]; + assert!(!is_sensitive_header("Content-Type", no_extra)); + assert!(!is_sensitive_header("Accept", no_extra)); + assert!(!is_sensitive_header("X-Request-Id", no_extra)); + assert!(!is_sensitive_header("User-Agent", no_extra)); + } + + #[test] + fn custom_auth_header_redacted() { + let extra = &["X-Custom-Auth"]; + assert!(is_sensitive_header("X-Custom-Auth", extra)); + assert!(is_sensitive_header("x-custom-auth", extra)); + // Static denylist still works alongside custom. + assert!(is_sensitive_header("authorization", extra)); + // Non-matching header is not redacted. + assert!(!is_sensitive_header("Content-Type", extra)); + } + + // -- Body redaction: JSON ------------------------------------- + + #[test] + fn json_top_level_password_redacted() { + let body = r#"{"username":"alice","password":"super-secret","client_secret":"xyz"}"#; + let redacted = redact_body(body); + let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed["username"], "alice"); + assert_eq!(parsed["password"], "[REDACTED]"); + assert_eq!(parsed["client_secret"], "[REDACTED]"); + } + + #[test] + fn json_nested_password_redacted() { + let body = r#"{"user":{"password":"nested-secret","name":"bob"}}"#; + let redacted = redact_body(body); + let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed["user"]["password"], "[REDACTED]"); + assert_eq!(parsed["user"]["name"], "bob"); + } + + #[test] + fn json_case_insensitive_key_match() { + let body = r#"{"Password":"upper","API_KEY":"k1","apiKey":"k2","Token":"t"}"#; + let redacted = redact_body(body); + let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed["Password"], "[REDACTED]"); + assert_eq!(parsed["API_KEY"], "[REDACTED]"); + assert_eq!(parsed["apiKey"], "[REDACTED]"); + assert_eq!(parsed["Token"], "[REDACTED]"); + } + + #[test] + fn json_array_with_sensitive_keys() { + let body = r#"[{"password":"s1"},{"password":"s2","name":"c"}]"#; + let redacted = redact_body(body); + let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed[0]["password"], "[REDACTED]"); + assert_eq!(parsed[1]["password"], "[REDACTED]"); + assert_eq!(parsed[1]["name"], "c"); + } + + #[test] + fn json_all_sensitive_keys_redacted() { + let body = r#"{ + "password":"p","client_secret":"cs","refresh_token":"rt", + "access_token":"at","api_key":"ak","apikey":"ak2", + "secret":"s","token":"t","safe_field":"ok" + }"#; + let redacted = redact_body(body); + let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed["password"], "[REDACTED]"); + assert_eq!(parsed["client_secret"], "[REDACTED]"); + assert_eq!(parsed["refresh_token"], "[REDACTED]"); + assert_eq!(parsed["access_token"], "[REDACTED]"); + assert_eq!(parsed["api_key"], "[REDACTED]"); + assert_eq!(parsed["apikey"], "[REDACTED]"); + assert_eq!(parsed["secret"], "[REDACTED]"); + assert_eq!(parsed["token"], "[REDACTED]"); + assert_eq!(parsed["safe_field"], "ok"); + } + + // -- Body redaction: form-encoded ----------------------------- + + #[test] + fn form_encoded_body_redaction() { + let body = "username=alice&password=super-secret&client_secret=xyz"; + let redacted = redact_body(body); + assert!(redacted.contains("username=alice")); + assert!(redacted.contains("password=[REDACTED]")); + assert!(redacted.contains("client_secret=[REDACTED]")); + assert!(!redacted.contains("super-secret")); + assert!(!redacted.contains("xyz")); + } + + #[test] + fn form_encoded_case_insensitive() { + let body = "Password=upper&API_KEY=k1"; + let redacted = redact_body(body); + assert!(redacted.contains("Password=[REDACTED]")); + assert!(redacted.contains("API_KEY=[REDACTED]")); + } + + // -- Body redaction: non-JSON, non-form ----------------------- + + #[test] + fn non_json_non_form_body_left_untouched() { + let body = "Hello, this is plain text with no special structure."; + let redacted = redact_body(body); + assert_eq!(redacted, body); + } + + #[test] + fn multiline_body_not_treated_as_form() { + let body = "line1\npassword=secret"; + let redacted = redact_body(body); + // Multi-line body should not be treated as form-encoded. + assert_eq!(redacted, body); + } + + // -- Body preview: JSON pretty-print gates on object/array ---- + + #[test] + fn bare_json_scalar_not_pretty_printed() { + // `null`, numbers, bools, and bare strings are valid JSON but + // should be shown raw, not as misleading single-token output. + let cases = vec!["null", "42", "true", r#""oops""#]; + for case in cases { + // Just verify redact_body doesn't mangle it. + let redacted = redact_body(case); + assert_eq!(redacted, case); + } + } + + // -- Truncation ----------------------------------------------- + + #[test] + fn body_preview_truncation() { + let long_body = "x".repeat(MAX_BODY_DISPLAY + 100); + let mut end = MAX_BODY_DISPLAY; + while !long_body.is_char_boundary(end) { + end -= 1; + } + let display = &long_body[..end]; + assert_eq!(display.len(), MAX_BODY_DISPLAY); + } + + #[test] + fn body_preview_truncation_multibyte() { + // Each euro sign is 3 bytes; boundary will fall mid-character. + let long_body = "\u{20ac}".repeat(MAX_BODY_DISPLAY); + let mut end = MAX_BODY_DISPLAY; + while !long_body.is_char_boundary(end) { + end -= 1; + } + let display = &long_body[..end]; + assert!(display.len() <= MAX_BODY_DISPLAY); + assert!(std::str::from_utf8(display.as_bytes()).is_ok()); + } + + // -- Smoke: no panics ----------------------------------------- + + #[test] + fn dump_request_does_not_panic() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers.insert("authorization", "Bearer secret123".parse().unwrap()); + dump_request( + "POST", + "https://api.example.com/v1/users", + &headers, + Some(r#"{"name":"test"}"#), + &[], + &[], + ); + } + + #[test] + fn dump_request_with_custom_auth_header() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers.insert("x-custom-auth", "my-secret-key".parse().unwrap()); + // Without extra_sensitive_headers, the custom header is not redacted. + // With it, it is. + dump_request( + "GET", + "https://api.example.com/v1/me", + &headers, + None, + &["X-Custom-Auth"], + &[], + ); + } + + #[test] + fn dump_response_does_not_panic() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + dump_response(200, 42, &headers, r#"{"id": 1}"#, &[]); + } + + #[test] + fn dump_error_response_does_not_panic() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + dump_error_response(404, 15, &headers, r#"{"error": "not found"}"#, &[]); + } + + // -- Gap 1: hybrid body-key matching (compound password variants) --- + + #[test] + fn compound_password_keys_redacted() { + for key in &[ + "new_password", + "current_password", + "old_password", + "password_confirmation", + "confirm_password", + ] { + assert!( + is_sensitive_body_key(key), + "`{key}` should be flagged as sensitive" + ); + } + } + + #[test] + fn compound_token_keys_redacted() { + for key in &["id_token", "access_token", "refresh_token", "bearer_token"] { + assert!( + is_sensitive_body_key(key), + "`{key}` should be flagged as sensitive" + ); + } + } + + #[test] + fn private_key_and_api_variants_redacted() { + for key in &[ + "private_key", + "private_key_jwt", + "client_secret", + "api_key", + "apiKey", + "API_KEY", + ] { + assert!( + is_sensitive_body_key(key), + "`{key}` should be flagged as sensitive (case-insensitive)" + ); + } + } + + #[test] + fn short_exact_stems_redacted() { + for key in &["pwd", "jwt", "bearer", "cookie", "token", "secret"] { + assert!( + is_sensitive_body_key(key), + "`{key}` (exact short form) should be flagged as sensitive" + ); + } + } + + #[test] + fn safe_keys_not_over_matched() { + for key in &["username", "email", "id", "name", "count", "tokenizer", "secretary"] { + assert!( + !is_sensitive_body_key(key), + "`{key}` should NOT be flagged as sensitive" + ); + } + } + + // password_hint and passwordless ARE flagged — documented defensive + // over-redaction because "password" is a substring. + #[test] + fn password_adjacent_keys_defensively_redacted() { + for key in &["password_hint", "passwordless"] { + assert!( + is_sensitive_body_key(key), + "`{key}` contains 'password' substring; defensive over-redaction is expected" + ); + } + } + + // -- Gap 2: response body redaction --------------------------- + + #[test] + fn redact_body_catches_response_tokens() { + let body = r#"{"access_token":"REAL_TOKEN","refresh_token":"ALSO","other":"keep"}"#; + let redacted = redact_body(body); + let parsed: serde_json::Value = serde_json::from_str(&redacted).unwrap(); + assert_eq!(parsed["access_token"], "[REDACTED]"); + assert_eq!(parsed["refresh_token"], "[REDACTED]"); + assert_eq!(parsed["other"], "keep"); + assert!( + !redacted.contains("REAL_TOKEN"), + "cleartext token must not appear in redacted output" + ); + } + + // -- Gap 3: URL query-string redaction ------------------------ + + #[test] + fn url_query_api_key_redacted() { + let url = "https://api.example.com/v1/data?api_key=secret123&page=2"; + let safe = redact_url_query(url, &[]); + assert!(safe.contains("api_key=%5BREDACTED%5D") || safe.contains("api_key=[REDACTED]")); + assert!(safe.contains("page=2")); + assert!(!safe.contains("secret123")); + } + + #[test] + fn url_query_password_redacted_page_preserved() { + let url = "https://api.example.com/v1/data?password=foo&page=2"; + let safe = redact_url_query(url, &[]); + assert!(!safe.contains("foo"), "password value should be redacted"); + assert!(safe.contains("page=2")); + } + + #[test] + fn url_query_no_query_unchanged() { + let url = "https://api.example.com/v1/data"; + let safe = redact_url_query(url, &[]); + assert_eq!(safe, url); + } + + #[test] + fn url_query_extra_spec_param_redacted() { + // Simulates an ApiKeyQuery scheme with name "my_key". + let url = "https://api.example.com/v1/data?my_key=s3cr3t&page=1"; + let safe = redact_url_query(url, &["my_key"]); + assert!(!safe.contains("s3cr3t"), "extra spec param should be redacted"); + assert!(safe.contains("page=1")); + } + + // -- New tests: curl-style format verification ---------------- + + /// Verify that the source file does not contain box-drawing characters. + #[test] + fn no_box_drawing_characters_in_source() { + // The source text of this file is embedded at compile time to assert + // that no unicode box characters remain. + const SOURCE: &str = include_str!("debug.rs"); + assert!( + !SOURCE.contains('\u{2500}'), + "unicode box character (U+2500) found in src/debug.rs — remove all box separators" + ); + } + + // -- New tests: dump_streaming_note --------------------------- + + #[test] + fn dump_streaming_note_does_not_panic() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "text/event-stream".parse().unwrap()); + headers.insert("authorization", "Bearer token123".parse().unwrap()); + // Should not panic — auth header is redacted via static denylist. + dump_streaming_note(200, &headers, &[]); + } + + #[test] + fn dump_streaming_note_no_panic_error_status() { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/octet-stream".parse().unwrap()); + dump_streaming_note(206, &headers, &[]); + } + + #[test] + fn dump_streaming_note_empty_headers_no_panic() { + let headers = HeaderMap::new(); + dump_streaming_note(200, &headers, &[]); + } + + // -- New headers aligned with TypeScript SDK ------------------ + + #[test] + fn new_sensitive_headers_detected() { + for header in &[ + "www-authenticate", + "api-key", + "apikey", + "x-api-token", + "auth-token", + "proxy-authenticate", + "x-xsrf-token", + "x-access-token", + ] { + assert!( + is_sensitive_header(header, &[]), + "`{header}` should be in the redacted-headers list" + ); + } + } + + // -- New body-key stems aligned with TypeScript SDK ----------- + + #[test] + fn key_and_session_exact_stems_redacted() { + for key in &["key", "session"] { + assert!( + is_sensitive_body_key(key), + "`{key}` (exact short form) should be flagged as sensitive" + ); + } + } + + #[test] + fn hyphenated_compound_stems_redacted() { + for key in &[ + "access-token", + "auth-token", + "api-key", + "api-secret", + "session-id", + ] { + assert!( + is_sensitive_body_key(key), + "`{key}` (hyphenated compound) should be flagged as sensitive" + ); + } + } + + #[test] + fn apisecret_redacted() { + assert!(is_sensitive_body_key("apisecret")); + assert!(is_sensitive_body_key("APISECRET")); + } + + #[test] + fn safe_keys_not_over_matched_extended() { + // Make sure new stems don't over-match + for key in &["monkey", "donkey", "keyboard", "bucket", "socket"] { + assert!( + !is_sensitive_body_key(key), + "`{key}` should NOT be flagged as sensitive" + ); + } + } + + // -- URL credential redaction --------------------------------- + + #[test] + fn url_credentials_redacted() { + let url = "https://user:hunter2@api.example.com/v1/data"; + let safe = redact_url_query(url, &[]); + assert!(!safe.contains("hunter2"), "password should be redacted from URL"); + assert!(!safe.contains("user:"), "username should be redacted from URL"); + assert!(safe.contains("api.example.com"), "host should be preserved"); + } + + #[test] + fn url_no_credentials_unchanged() { + let url = "https://api.example.com/v1/data?page=2"; + let safe = redact_url_query(url, &[]); + assert!(safe.contains("page=2")); + assert!(!safe.contains("[REDACTED]")); + } + + // -- dump_graphql_request: variable redaction ----------------- + + /// `dump_graphql_request` must redact sensitive keys in `variables` while + /// leaving non-sensitive keys intact. Tested via `redact_json_value` + /// directly since the dump goes to stderr and is not easily capturable + /// in unit tests. The wire tests cover the subprocess output. + #[test] + fn graphql_variables_password_key_redacted_before_dump() { + use serde_json::json; + let mut vars = json!({"username": "alice", "password": "s3cr3t", "page": 1}); + redact_json_value(&mut vars); + assert_eq!(vars["username"], "alice", "non-sensitive key must be preserved"); + assert_eq!(vars["password"], "[REDACTED]", "password key must be redacted"); + assert_eq!(vars["page"], 1, "non-sensitive key must be preserved"); + } + + #[test] + fn graphql_variables_nested_secret_redacted() { + use serde_json::json; + let mut vars = json!({"auth": {"client_secret": "tok", "scope": "read"}}); + redact_json_value(&mut vars); + assert_eq!(vars["auth"]["client_secret"], "[REDACTED]"); + assert_eq!(vars["auth"]["scope"], "read"); + } + + #[test] + fn dump_graphql_request_does_not_panic_with_empty_variables() { + use serde_json::Value; + let headers = HeaderMap::new(); + dump_graphql_request( + "https://api.example.com/graphql", + &headers, + "query { ping }", + &Value::Object(serde_json::Map::new()), + &[], + ); + } + + #[test] + fn dump_graphql_request_does_not_panic_with_populated_variables() { + use serde_json::json; + let mut headers = HeaderMap::new(); + headers.insert("authorization", "Bearer tok".parse().unwrap()); + dump_graphql_request( + "https://api.example.com/graphql", + &headers, + "query($id: ID!) { node(id: $id) { id name } }", + &json!({"id": "n1", "password": "should-be-redacted"}), + &[], + ); + } + + #[test] + fn vendor_specific_credential_headers_are_redacted_without_a_security_scheme() { + // The leak this closes: a spec can model its credential as a plain + // header parameter with a vendor-specific name and declare no + // `securitySchemes` at all, so neither the exact-match list nor the + // spec-derived names catch it. `--debug` and `--dry-run` printed it. + for name in [ + "xi-api-key", + "XI-API-KEY", + "x-goog-api-key", + "My-Api_Key", + "x-refresh-token", + "x-client-secret", + "db-password", + "x-credential", + ] { + assert!( + is_sensitive_header(name, &[]), + "{name} should be treated as a credential" + ); + } + } + + #[test] + fn useful_non_credential_headers_stay_legible() { + // Over-redaction has a real cost when debugging, so headers that are + // merely useful must survive. `idempotency-key` is the one to protect: + // it contains "key" but is not a secret, and it matters when + // investigating retries. + for name in [ + "idempotency-key", + // CORS response headers: `access-control-allow-credentials` + // contains "credential" but is a browser-permissions flag, not a + // secret. Redacting it hid something harmless. + "access-control-allow-credentials", + "access-control-allow-headers", + "content-type", + "content-length", + "accept", + "accept-encoding", + "user-agent", + "x-request-id", + "retry-after", + "host", + ] { + assert!( + !is_sensitive_header(name, &[]), + "{name} should not be redacted" + ); + } + } +} diff --git a/src/early_intercept.rs b/src/early_intercept.rs new file mode 100644 index 0000000..78aff4c --- /dev/null +++ b/src/early_intercept.rs @@ -0,0 +1,235 @@ +//! Shared infrastructure for early-intercept subcommands (`completion`, `man`). +//! +//! These subcommands are intercepted *before* normal API dispatch so that +//! an API resource that happens to share the same name doesn't collide. +//! This module houses the constants and helpers shared by both intercept +//! paths. + +/// Long flag names (without the `--` prefix) that are boolean +/// (`action(SetTrue)`) and therefore do NOT consume the next token. +/// Kept in sync with the flags registered in `commands::build_cli`. +pub(crate) const BOOLEAN_FLAGS: &[&str] = &[ + "debug", + "dry-run", + "help", + "no-extract", + "no-pager", + "no-retry", + "no-stream", + "page-all", + "q", + "quiet", +]; + +/// Returns `true` when `args` contains `target` as the first positional +/// token (i.e. the subcommand position). Skips `--flag value` pairs so +/// `box --base-url files` is not mistaken for the subcommand. +/// Boolean flags like `--dry-run` are recognised and do NOT consume the +/// next token. +pub(crate) fn first_positional_is(args: &[String], target: &str) -> bool { + let mut skip_next = false; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg.starts_with('-') { + if arg.contains('=') { + // --flag=value — value is consumed inline, no skip. + continue; + } + // Strip leading dashes to get the bare name. + let bare = arg.trim_start_matches('-'); + if !BOOLEAN_FLAGS.contains(&bare) { + // Value-taking flag — next token is its argument. + skip_next = true; + } + continue; + } + return arg == target; + } + false +} + +/// Returns the n-th positional argument (0-indexed, ignoring argv[0]), +/// correctly skipping value-taking flags' arguments per [`BOOLEAN_FLAGS`]. +/// +/// This is the multi-positional generalization of [`first_positional_is`]: +/// `first_positional_is(args, target)` is equivalent to +/// `nth_positional(args, 0) == Some(target)`. +/// +/// Used by the completion early-intercept path to extract the shell name +/// (positional #1, since `completion` is positional #0) while correctly +/// skipping value-taking flag arguments like `--base-url `. +pub(crate) fn nth_positional(args: &[String], n: usize) -> Option<&str> { + let mut skip_next = false; + let mut count = 0; + for arg in args.iter().skip(1) { + if skip_next { + skip_next = false; + continue; + } + if arg.starts_with('-') { + if arg.contains('=') { + // --flag=value — value is consumed inline, no skip. + continue; + } + // Strip leading dashes to get the bare name. + let bare = arg.trim_start_matches('-'); + if !BOOLEAN_FLAGS.contains(&bare) { + // Value-taking flag — next token is its argument. + skip_next = true; + } + continue; + } + if count == n { + return Some(arg.as_str()); + } + count += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn first_positional_basic() { + assert!(first_positional_is(&args(&["box", "completion", "bash"]), "completion")); + assert!(first_positional_is(&args(&["box", "man"]), "man")); + } + + #[test] + fn first_positional_false_for_other_subcommand() { + assert!(!first_positional_is(&args(&["box", "files", "get"]), "completion")); + } + + #[test] + fn first_positional_false_when_flag_value() { + assert!(!first_positional_is( + &args(&["box", "--base-url", "man", "files"]), + "man", + )); + } + + #[test] + fn first_positional_true_after_eq_flag() { + assert!(first_positional_is( + &args(&["box", "--base-url=http://localhost", "man"]), + "man", + )); + } + + #[test] + fn first_positional_true_after_boolean_flag() { + assert!(first_positional_is( + &args(&["box", "--dry-run", "completion", "bash"]), + "completion", + )); + } + + #[test] + fn first_positional_true_after_debug_flag() { + // `--debug` is boolean (SetTrue); "completion" must remain positional #0. + assert!(first_positional_is( + &args(&["box", "--debug", "completion", "bash"]), + "completion", + )); + } + + #[test] + fn nth_positional_with_debug_flag() { + // `--debug` must not swallow "completion"; "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["box", "--debug", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn first_positional_true_after_quiet_flag() { + // `--quiet` is boolean (SetTrue); "completion" must remain positional #0. + assert!(first_positional_is( + &args(&["box", "--quiet", "completion", "bash"]), + "completion", + )); + // Short form `-q` must behave identically. + assert!(first_positional_is( + &args(&["box", "-q", "completion", "bash"]), + "completion", + )); + } + + #[test] + fn nth_positional_with_quiet_flag() { + // `--quiet` must not swallow "completion"; "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["box", "--quiet", "completion", "bash"]), 1), + Some("bash"), + ); + // Short form `-q` must behave identically. + assert_eq!( + nth_positional(&args(&["box", "-q", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn first_positional_true_after_multiple_boolean_flags() { + assert!(first_positional_is( + &args(&["box", "--dry-run", "--no-retry", "man"]), + "man", + )); + } + + // --- nth_positional --- + + #[test] + fn nth_positional_skips_value_flag() { + // `--base-url` is value-taking, so "X" is its argument, not a + // positional. "completion" is positional #0, "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["box", "--base-url", "X", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn nth_positional_with_boolean_flag() { + // `--dry-run` is boolean, so "completion" is positional #0 and + // "bash" is positional #1. + assert_eq!( + nth_positional(&args(&["box", "--dry-run", "completion", "bash"]), 1), + Some("bash"), + ); + } + + #[test] + fn nth_positional_out_of_range() { + assert_eq!( + nth_positional(&args(&["box", "completion", "bash"]), 5), + None, + ); + } + + #[test] + fn nth_positional_zeroth() { + assert_eq!( + nth_positional(&args(&["box", "completion", "bash"]), 0), + Some("completion"), + ); + } + + #[test] + fn nth_positional_eq_flag() { + assert_eq!( + nth_positional(&args(&["box", "--base-url=http://localhost", "completion", "bash"]), 1), + Some("bash"), + ); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2bd6e66 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,719 @@ +//! Structured Error Types +//! +//! Provides error types and structured JSON error output for the CLI. + +use serde_json::json; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum CliError { + #[error("{message}")] + Api { + code: u16, + message: String, + reason: String, + }, + + #[error("{0}")] + Validation(String), + + #[error("{0}")] + Auth(String), + + #[error("{0}")] + Discovery(String), + + #[error(transparent)] + Other(#[from] anyhow::Error), + + /// Raw-mode sentinel: error bytes already written to stdout. + #[error("")] + RawSentinel { code: u16 }, +} + + +impl CliError { + pub const EXIT_CODE_API: i32 = 1; + pub const EXIT_CODE_AUTH: i32 = 2; + pub const EXIT_CODE_VALIDATION: i32 = 3; + pub const EXIT_CODE_DISCOVERY: i32 = 4; + pub const EXIT_CODE_OTHER: i32 = 5; + + /// Create a duplicate of this error for passing to hook callbacks + /// while retaining the original. `Other(anyhow::Error)` is + /// converted to its display string since `anyhow::Error` is not + /// `Clone`. + pub fn duplicate(&self) -> Self { + match self { + Self::Api { code, message, reason } => Self::Api { + code: *code, + message: message.clone(), + reason: reason.clone(), + }, + Self::Validation(msg) => Self::Validation(msg.clone()), + Self::Auth(msg) => Self::Auth(msg.clone()), + Self::Discovery(msg) => Self::Discovery(msg.clone()), + Self::Other(e) => Self::Other(anyhow::anyhow!("{e:#}")), + Self::RawSentinel { code } => Self::RawSentinel { code: *code }, + } + } + + /// Whether this is a raw-mode sentinel (error bytes already on stdout). + pub fn is_raw_sentinel(&self) -> bool { + matches!(self, Self::RawSentinel { .. }) + } + + pub fn exit_code(&self) -> i32 { + match self { + CliError::Api { .. } => Self::EXIT_CODE_API, + CliError::Auth(_) => Self::EXIT_CODE_AUTH, + CliError::Validation(_) => Self::EXIT_CODE_VALIDATION, + CliError::Discovery(_) => Self::EXIT_CODE_DISCOVERY, + CliError::Other(_) => Self::EXIT_CODE_OTHER, + CliError::RawSentinel { .. } => Self::EXIT_CODE_API, + } + } + + pub fn to_json(&self) -> serde_json::Value { + match self { + CliError::Api { + code, + message, + reason, + } => json!({ + "error": { + "code": code, + "message": message, + "reason": reason, + } + }), + CliError::Validation(msg) => json!({ + "error": { + "code": 400, + "message": msg, + "reason": "validationError", + } + }), + CliError::Auth(msg) => json!({ + "error": { + "code": 401, + "message": msg, + "reason": "authError", + } + }), + CliError::Discovery(msg) => json!({ + "error": { + "code": 500, + "message": msg, + "reason": "discoveryError", + } + }), + CliError::Other(e) => json!({ + "error": { + "code": 500, + "message": format!("{e:#}"), + "reason": "internalError", + } + }), + CliError::RawSentinel { code } => json!({ + "error": { + "code": code, + "message": "", + "reason": "raw", + } + }), + } + } +} + +use crate::output::{colorize, sanitize_for_terminal}; + +/// All documented exit codes with their human-readable descriptions. +pub const EXIT_CODE_TABLE: &[(i32, &str, &str)] = &[ + (CliError::EXIT_CODE_API, "api", "API returned a non-success HTTP status"), + (CliError::EXIT_CODE_AUTH, "auth", "Authentication failed or credentials missing"), + (CliError::EXIT_CODE_VALIDATION, "validation", "Invalid arguments or request body"), + (CliError::EXIT_CODE_DISCOVERY, "discovery", "Schema loading or endpoint resolution failed"), + (CliError::EXIT_CODE_OTHER, "other", "Unexpected internal error"), +]; + +/// Render all documented exit codes to stdout in the format requested +/// by the user's raw args. +/// +/// Honors `--format json` (and equivalents) so AI agents can consume a +/// machine-readable inventory of exit codes — the whole point of this +/// command for scripting workflows. Unknown `--format` values fall +/// back to the human-readable table, matching the resolver behavior +/// elsewhere in the CLI. +pub fn print_errors(args: &[String]) { + write_errors_to(args, &mut std::io::stdout()); +} + +/// Writer-parameterized variant of [`print_errors`]. +pub fn write_errors_to(args: &[String], out: &mut dyn std::io::Write) { + match detect_errors_format(args) { + ErrorsFormat::Json => write_errors_json_to(out), + ErrorsFormat::Table => write_errors_table_to(out), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ErrorsFormat { + Table, + Json, +} + +fn detect_errors_format(args: &[String]) -> ErrorsFormat { + for (i, a) in args.iter().enumerate() { + if let Some(rest) = a.strip_prefix("--format=") { + if rest.eq_ignore_ascii_case("json") { + return ErrorsFormat::Json; + } + } else if a == "--format" { + if let Some(next) = args.get(i + 1) { + if next.eq_ignore_ascii_case("json") { + return ErrorsFormat::Json; + } + } + } + } + ErrorsFormat::Table +} + +/// Print a human-readable table of all exit codes to stdout. +pub fn print_errors_table() { + write_errors_table_to(&mut std::io::stdout()); +} + +fn write_errors_table_to(out: &mut dyn std::io::Write) { + let _ = writeln!(out, "Exit codes:\n"); + let _ = writeln!(out, " {:<6} {:<14} DESCRIPTION", "CODE", "CATEGORY"); + let _ = writeln!(out, " {:<6} {:<14} ───────────────────────────────────────────", "──────", "──────────────"); + for &(code, category, description) in EXIT_CODE_TABLE { + let _ = writeln!(out, " {:<6} {:<14} {}", code, category, description); + } + let _ = writeln!(out); + let _ = writeln!(out, "Exit code 0 means success. Any non-zero code indicates an error."); +} + +/// Print all documented exit codes as a JSON array on stdout. +/// +/// Shape: +/// ```json +/// { +/// "exit_codes": [ +/// {"code": 0, "category": "success", "description": "..."}, +/// {"code": 1, "category": "api", "description": "..."}, +/// ... +/// ] +/// } +/// ``` +/// +/// Includes the implicit success code (0) so consumers see the full +/// matrix without having to special-case the success path. +pub fn print_errors_json() { + write_errors_json_to(&mut std::io::stdout()); +} + +fn write_errors_json_to(out: &mut dyn std::io::Write) { + let mut entries: Vec = Vec::with_capacity(EXIT_CODE_TABLE.len() + 1); + entries.push(json!({ + "code": 0, + "category": "success", + "description": "Command completed successfully", + })); + for &(code, category, description) in EXIT_CODE_TABLE { + entries.push(json!({ + "code": code, + "category": category, + "description": description, + })); + } + let doc = json!({ "exit_codes": entries }); + let _ = writeln!(out, "{}", serde_json::to_string_pretty(&doc).expect("static EXIT_CODE_TABLE always serializes")); +} + +fn error_label(err: &CliError) -> String { + match err { + CliError::Api { .. } => colorize("error[api]:", "31"), + CliError::Auth(_) => colorize("error[auth]:", "31"), + CliError::Validation(_) => colorize("error[validation]:", "33"), + CliError::Discovery(_) => colorize("error[discovery]:", "31"), + CliError::Other(_) => colorize("error:", "31"), + CliError::RawSentinel { .. } => colorize("error[api]:", "31"), + } +} + +/// Optional context that enriches the stderr error display with a docs link +/// and a `--help` suggestion. Does not affect the JSON envelope on stdout. +pub struct ErrorDisplayContext { + /// Base URL for per-code documentation links (e.g. `https://docs.example.com/errors/`). + /// Appended with the HTTP status code for `CliError::Api` errors. + pub docs_base_url: Option, + /// Full help invocation, e.g. `box users list --help`. + /// Printed as `Try \`...\`` after the error message. + pub help_hint: Option, +} + +pub fn print_error_json(err: &CliError) { + write_error_json(err, &mut std::io::stdout(), None); +} + +pub fn write_error_json(err: &CliError, out: &mut dyn std::io::Write, ctx: Option<&ErrorDisplayContext>) { + // Raw-mode sentinel: bytes already on stdout, skip structured JSON. + if let CliError::RawSentinel { code } = err { + eprintln!("Error: HTTP {code}"); + return; + } + let json = err.to_json(); + let _ = writeln!( + out, + "{}", + serde_json::to_string_pretty(&json).unwrap_or_default() + ); + eprintln!( + "{} {}", + error_label(err), + sanitize_for_terminal(&err.to_string()) + ); + if let Some(ctx) = ctx { + if let Some(base) = &ctx.docs_base_url { + if let CliError::Api { code, .. } = err { + let url = format!("{}/{}", base.trim_end_matches('/'), code); + eprintln!(" → {}", sanitize_for_terminal(&url)); + } + } + if matches!(err, CliError::Validation(_)) { + if let Some(hint) = &ctx.help_hint { + // `--help` is the right next step for a malformed flag, but not + // when the message already tells the user exactly what to set — + // a refused cross-host redirect, for instance, is remedied by an + // environment variable, and `Try --help` sends them to a + // flag list that says nothing about it. + if !message_names_its_own_remedy(&err.to_string()) { + eprintln!(" Try `{}`", sanitize_for_terminal(hint)); + } + } + } + } +} + +/// Marker shared with the security guards in [`crate::http`], whose refusal +/// messages end by naming the environment variable that permits the action. +/// +/// Kept as one constant so the guards and this check cannot drift apart; the +/// test below builds a real refusal and asserts it still matches. +pub(crate) const SELF_REMEDY_MARKER: &str = "=1 to allow it"; + +/// Whether a validation message already states its own remedy, making a generic +/// `Try --help` redundant or actively misleading. +/// +/// Deliberately narrow — the default stays "show the hint", because for the +/// overwhelming majority of validation errors (a bad flag value, a missing +/// required parameter) `--help` is exactly where the user should look. It is +/// only suppressed when the fix lives in the environment rather than in the +/// command's flags, where pointing at a flag list would send the user somewhere +/// that says nothing about it. +fn message_names_its_own_remedy(message: &str) -> bool { + message.contains(SELF_REMEDY_MARKER) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn self_remedy_marker_matches_a_real_guard_refusal() { + // Built from the pagination guard rather than a hand-copied string, so + // rewording the guard breaks this test instead of silently restoring the + // misleading `Try ... --help` hint. + let refusal = crate::http::check_pagination_target( + "hintcheck", + "https://api.example.com/v1/things", + "https://evil.example.net/v1/things", + ) + .expect_err("a cross-host pagination target must be refused"); + assert!( + message_names_its_own_remedy(&refusal), + "the guard's message should suppress the --help hint, got: {refusal}" + ); + } + + #[test] + fn ordinary_validation_messages_still_get_the_help_hint() { + // The common case must be unaffected: a bad flag or missing parameter + // has no env-var remedy, so `--help` is the right pointer. + for message in [ + "Required parameter 'query' is missing. Provide it via --query-param or --params", + "Cannot combine --json with per-field body flags (--type). Use one or the other.", + "Invalid --params JSON: expected value at line 1 column 1", + ] { + assert!( + !message_names_its_own_remedy(message), + "{message} should keep the --help hint" + ); + } + } + + #[test] + fn test_exit_codes_are_distinct() { + let codes = [ + CliError::EXIT_CODE_API, + CliError::EXIT_CODE_AUTH, + CliError::EXIT_CODE_VALIDATION, + CliError::EXIT_CODE_DISCOVERY, + CliError::EXIT_CODE_OTHER, + ]; + let unique: std::collections::HashSet = codes.iter().copied().collect(); + assert_eq!(unique.len(), codes.len()); + } + + #[test] + fn test_error_to_json_api() { + let err = CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + }; + let json = err.to_json(); + assert_eq!(json["error"]["code"], 404); + assert_eq!(json["error"]["message"], "Not Found"); + } + + #[test] + fn test_error_to_json_validation() { + let err = CliError::Validation("Invalid input".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 400); + } + + #[test] + fn test_exit_codes_all_variants() { + assert_eq!( + CliError::Api { code: 404, message: String::new(), reason: String::new() }.exit_code(), + CliError::EXIT_CODE_API + ); + assert_eq!(CliError::Auth(String::new()).exit_code(), CliError::EXIT_CODE_AUTH); + assert_eq!(CliError::Validation(String::new()).exit_code(), CliError::EXIT_CODE_VALIDATION); + assert_eq!(CliError::Discovery(String::new()).exit_code(), CliError::EXIT_CODE_DISCOVERY); + assert_eq!( + CliError::Other(anyhow::anyhow!("oops")).exit_code(), + CliError::EXIT_CODE_OTHER + ); + } + + #[test] + fn test_to_json_auth() { + let err = CliError::Auth("bad creds".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 401); + assert_eq!(json["error"]["reason"], "authError"); + } + + #[test] + fn test_to_json_discovery() { + let err = CliError::Discovery("spec not found".to_string()); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 500); + assert_eq!(json["error"]["reason"], "discoveryError"); + assert_eq!(json["error"]["message"], "spec not found"); + } + + #[test] + fn test_to_json_other() { + let err = CliError::Other(anyhow::anyhow!("something broke")); + let json = err.to_json(); + assert_eq!(json["error"]["code"], 500); + assert_eq!(json["error"]["reason"], "internalError"); + } + + #[test] + fn test_print_error_json_all_variants_no_panic() { + print_error_json(&CliError::Api { + code: 500, + message: "oops".to_string(), + reason: "err".to_string(), + }); + print_error_json(&CliError::Validation("bad input".to_string())); + print_error_json(&CliError::Auth("no auth".to_string())); + print_error_json(&CliError::Discovery("no spec".to_string())); + print_error_json(&CliError::Other(anyhow::anyhow!("broken"))); + } + + #[test] + fn write_error_json_stdout_unchanged_with_context() { + let err = CliError::Api { + code: 401, + message: "Unauthorized".to_string(), + reason: "authError".to_string(), + }; + let ctx = ErrorDisplayContext { + docs_base_url: Some("https://docs.example.com/errors".to_string()), + help_hint: Some("mycli users list --help".to_string()), + }; + let mut out = Vec::new(); + write_error_json(&err, &mut out, Some(&ctx)); + let stdout = String::from_utf8(out).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed["error"]["code"], 401); + assert_eq!(parsed["error"]["message"], "Unauthorized"); + } + + #[test] + fn write_error_json_no_docs_url_for_non_api_errors() { + let ctx = ErrorDisplayContext { + docs_base_url: Some("https://docs.example.com/errors".to_string()), + help_hint: None, + }; + // Validation errors should not get docs URLs (no HTTP status code). + let mut out = Vec::new(); + write_error_json( + &CliError::Validation("bad input".to_string()), + &mut out, + Some(&ctx), + ); + let stdout = String::from_utf8(out).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed["error"]["code"], 400); + } + + #[test] + fn validation_label_is_error_validation() { + let label = error_label(&CliError::Validation("oops".to_string())); + assert!(label.contains("error[validation]"), "expected 'error[validation]:' label, got: {label}"); + assert!(!label.contains("warning:"), "label should not contain 'warning:'"); + } + + #[test] + fn help_hint_shown_only_for_validation_errors() { + let ctx = ErrorDisplayContext { + docs_base_url: None, + help_hint: Some("mycli users list --help".to_string()), + }; + // Validation errors should get the hint. + let mut out = Vec::new(); + write_error_json(&CliError::Validation("bad input".to_string()), &mut out, Some(&ctx)); + // Stdout is the JSON envelope — we don't assert stderr here since eprintln + // always targets the real stderr in unit tests. The gating logic is covered + // by the `matches!` branch; the wire test exercises it end-to-end. + + // Non-Validation variants must NOT produce a hint. Verify the branch + // is unreachable for Api/Auth/Discovery/Other by asserting the helper + // doesn't panic and returns clean JSON. + for err in [ + CliError::Api { code: 401, message: "denied".to_string(), reason: "authError".to_string() }, + CliError::Auth("missing token".to_string()), + CliError::Discovery("no spec".to_string()), + CliError::Other(anyhow::anyhow!("boom")), + ] { + let mut o = Vec::new(); + write_error_json(&err, &mut o, Some(&ctx)); + assert!(serde_json::from_str::(&String::from_utf8(o).unwrap()).is_ok()); + } + } + + #[test] + fn write_error_json_no_panic_without_context() { + let mut out = Vec::new(); + write_error_json( + &CliError::Api { code: 422, message: "invalid".to_string(), reason: "validationError".to_string() }, + &mut out, + None, + ); + let stdout = String::from_utf8(out).unwrap(); + assert!(serde_json::from_str::(&stdout).is_ok()); + } + + #[test] + fn test_duplicate_preserves_variant() { + let api = CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + }; + let dup = api.duplicate(); + assert_eq!(dup.exit_code(), CliError::EXIT_CODE_API); + assert_eq!(dup.to_json()["error"]["code"], 404); + + let val = CliError::Validation("bad".to_string()); + assert_eq!(val.duplicate().exit_code(), CliError::EXIT_CODE_VALIDATION); + + let auth = CliError::Auth("denied".to_string()); + assert_eq!(auth.duplicate().exit_code(), CliError::EXIT_CODE_AUTH); + + let disc = CliError::Discovery("missing".to_string()); + assert_eq!(disc.duplicate().exit_code(), CliError::EXIT_CODE_DISCOVERY); + + // Other(anyhow) preserves variant and exit code. + let other = CliError::Other(anyhow::anyhow!("anyhow msg")); + let dup_other = other.duplicate(); + assert_eq!(dup_other.exit_code(), CliError::EXIT_CODE_OTHER); + } + + #[test] + fn exit_code_table_covers_all_known_codes() { + let table_codes: std::collections::HashSet = + EXIT_CODE_TABLE.iter().map(|&(c, _, _)| c).collect(); + let expected = [ + CliError::EXIT_CODE_API, + CliError::EXIT_CODE_AUTH, + CliError::EXIT_CODE_VALIDATION, + CliError::EXIT_CODE_DISCOVERY, + CliError::EXIT_CODE_OTHER, + ]; + for code in expected { + assert!(table_codes.contains(&code), "EXIT_CODE_TABLE missing code {code}"); + } + } + + #[test] + fn exit_code_table_has_no_duplicates() { + let codes: Vec = EXIT_CODE_TABLE.iter().map(|&(c, _, _)| c).collect(); + let unique: std::collections::HashSet = codes.iter().copied().collect(); + assert_eq!(unique.len(), codes.len(), "EXIT_CODE_TABLE has duplicate codes"); + } + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn detect_errors_format_defaults_to_table() { + assert_eq!(detect_errors_format(&args(&["cli", "errors"])), ErrorsFormat::Table); + } + + #[test] + fn detect_errors_format_recognizes_json_space_separated() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format", "json"])), + ErrorsFormat::Json, + ); + } + + #[test] + fn detect_errors_format_recognizes_json_equals() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format=json"])), + ErrorsFormat::Json, + ); + } + + #[test] + fn detect_errors_format_case_insensitive() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format", "JSON"])), + ErrorsFormat::Json, + ); + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format=Json"])), + ErrorsFormat::Json, + ); + } + + #[test] + fn detect_errors_format_unknown_format_falls_back_to_table() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format", "yaml"])), + ErrorsFormat::Table, + ); + } + + #[test] + fn detect_errors_format_trailing_format_flag_with_no_value_is_table() { + assert_eq!( + detect_errors_format(&args(&["cli", "errors", "--format"])), + ErrorsFormat::Table, + ); + } + + #[test] + fn is_raw_sentinel_true_for_raw_sentinel_variant() { + let err = CliError::RawSentinel { code: 500 }; + assert!(err.is_raw_sentinel()); + } + + #[test] + fn is_raw_sentinel_false_for_api_with_raw_reason() { + // A server returning reason "raw" must NOT collide with the sentinel. + let err = CliError::Api { + code: 500, + message: String::new(), + reason: "raw".to_string(), + }; + assert!(!err.is_raw_sentinel()); + } + + #[test] + fn is_raw_sentinel_false_for_non_api_errors() { + assert!(!CliError::Validation("x".into()).is_raw_sentinel()); + assert!(!CliError::Auth("x".into()).is_raw_sentinel()); + assert!(!CliError::Discovery("x".into()).is_raw_sentinel()); + } + + #[test] + fn raw_sentinel_exit_code_matches_api() { + let sentinel = CliError::RawSentinel { code: 404 }; + assert_eq!(sentinel.exit_code(), CliError::EXIT_CODE_API); + } + + #[test] + fn raw_sentinel_duplicate() { + let sentinel = CliError::RawSentinel { code: 422 }; + let dup = sentinel.duplicate(); + assert!(dup.is_raw_sentinel()); + assert_eq!(dup.exit_code(), CliError::EXIT_CODE_API); + } + + #[test] + fn write_error_json_raw_sentinel_suppresses_stdout() { + let err = CliError::RawSentinel { code: 500 }; + let mut buf: Vec = Vec::new(); + write_error_json(&err, &mut buf, None); + assert!(buf.is_empty(), "raw sentinel should suppress stdout JSON, got: {:?}", String::from_utf8_lossy(&buf)); + } + + #[test] + fn write_error_json_normal_api_error_writes_json() { + let err = CliError::Api { + code: 404, + message: "Not Found".to_string(), + reason: "notFound".to_string(), + }; + let mut buf: Vec = Vec::new(); + write_error_json(&err, &mut buf, None); + assert!(!buf.is_empty(), "normal API error should write JSON to stdout"); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("Not Found")); + } + + #[test] + fn print_errors_json_emits_expected_shape() { + // Smoke: the JSON payload parses cleanly and includes every + // documented exit code (plus the implicit 0). Captures the + // contract that AI agents consume. + let mut entries: Vec = Vec::with_capacity(EXIT_CODE_TABLE.len() + 1); + entries.push(json!({ + "code": 0, + "category": "success", + "description": "Command completed successfully", + })); + for &(code, category, description) in EXIT_CODE_TABLE { + entries.push(json!({ + "code": code, + "category": category, + "description": description, + })); + } + let payload = json!({ "exit_codes": entries }); + let arr = payload["exit_codes"].as_array().expect("exit_codes is array"); + assert_eq!(arr.len(), EXIT_CODE_TABLE.len() + 1); + assert_eq!(arr[0]["code"], 0); + let codes: std::collections::HashSet = arr + .iter() + .filter_map(|e| e["code"].as_i64()) + .collect(); + for &(code, _, _) in EXIT_CODE_TABLE { + assert!(codes.contains(&(code as i64)), "missing code {code}"); + } + } +} diff --git a/src/formatter.rs b/src/formatter.rs new file mode 100644 index 0000000..32d8f23 --- /dev/null +++ b/src/formatter.rs @@ -0,0 +1,1637 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Output Formatting +//! +//! Transforms JSON API responses into human-readable formats (table, YAML, CSV). + +use serde_json::Value; +use std::fmt::Write; +use std::io::IsTerminal; + +/// Color emission mode. +/// +/// Resolved from CLI flags and environment in [`OutputPipeline::from_matches`]. +/// `Auto` means "let the resolver decide based on TTY / `NO_COLOR` / `CI` / etc." +/// (Resolver is implemented in Step 2; for now `Auto` is just stored.) +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub enum ColorMode { + #[default] + Auto, + Always, + Never, +} + +/// Errors that can occur while constructing or running the output pipeline. +#[derive(Debug, thiserror::Error)] +pub enum FormatError { + #[error("unknown output format: {0}")] + UnknownFormat(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("invalid --query expression: {0}")] + InvalidQuery(String), + #[error("--query evaluation failed: {0}")] + QueryEvaluation(String), +} + +/// Composable output pipeline. +/// +/// Built once at dispatch time from CLI matches, then threaded through the +/// executor and applied per response (or per page during `--page-all`). +/// +/// In Step 1 it carries only `format` and `color_mode` and behaves identically +/// to the prior `&OutputFormat` threading. Later steps layer in field +/// projection, jq filtering, and template rendering. +#[derive(Debug, Clone, Default)] +pub struct OutputPipeline { + pub format: OutputFormat, + pub color_mode: ColorMode, + /// When true, suppress all stdout output. Errors still flow to stderr. + pub quiet: bool, + /// Optional JMESPath expression applied to every response before formatting. + pub query: Option, +} + +impl OutputPipeline { + /// Build a pipeline from parsed CLI matches. + /// + /// Resolves the output format with this precedence when `--format` is + /// **not** passed: + /// 1. an explicit `--format` flag (always wins); + /// 2. the per-binary `_OUTPUT` env var, if set to a valid format + /// (`NAME` is `app_name` uppercased with `-` → `_`, mirroring the + /// `_LOG` logging convention); + /// 3. a TTY-aware default — `table` when stdout is an interactive + /// terminal, `json` when piped or redirected. + /// + /// An invalid `_OUTPUT` value is ignored (falls through to the + /// TTY-aware default), so a stray env var never breaks the CLI. + /// + /// Returns `Err(FormatError::UnknownFormat)` for unrecognised + /// `--format` values. Callers should map this into their error type + /// (e.g. `CliError::Validation`). + pub fn from_matches(matches: &clap::ArgMatches, app_name: &str) -> Result { + let format = match matches.get_one::("format") { + Some(s) => OutputFormat::parse(s).map_err(FormatError::UnknownFormat)?, + None => { + let env_var = format!("{}_OUTPUT", app_name.to_uppercase().replace('-', "_")); + let env_value = std::env::var(env_var).ok(); + resolve_default_format(env_value.as_deref(), std::io::stdout().is_terminal()) + } + }; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let query = matches + .try_get_one::("query") + .ok() + .flatten() + .cloned(); + // Validate the expression eagerly so typos are caught before the + // request is sent. + if let Some(ref expr) = query { + jmespath::compile(expr) + .map_err(|e| FormatError::InvalidQuery(e.to_string()))?; + } + Ok(Self { + format, + color_mode: ColorMode::Auto, + quiet, + query, + }) + } + + /// Whether the pipeline is in raw mode (bypass formatting). + pub fn is_raw(&self) -> bool { + self.format == OutputFormat::Raw + } + + /// Whether the pipeline is in HTTP mode (full HTTP response output). + pub fn is_http(&self) -> bool { + self.format == OutputFormat::Http + } + + /// Render `value` to `out`, appending a trailing newline. + /// + /// When `quiet` is set, this is a no-op — the value is silently discarded. + /// When a `--query` expression is set, it is applied before formatting. + pub fn emit( + &self, + out: &mut W, + value: &Value, + paginated: bool, + is_first_page: bool, + ) -> Result<(), FormatError> { + if self.quiet { + return Ok(()); + } + // Avoid cloning when no --query is set (the common path). + let owned; + let effective = match &self.query { + Some(_) => { + owned = self.apply_query(value)?; + &owned + } + None => value, + }; + let rendered = if paginated { + format_value_paginated(effective, &self.format, is_first_page) + } else { + format_value(effective, &self.format) + }; + writeln!(out, "{rendered}")?; + Ok(()) + } + + /// Render a pre-projected `value` to `out` without applying `--query`. + /// + /// Used by streaming paths that have already applied `apply_query_streaming` + /// and want to emit the result without re-projecting. + pub fn emit_raw( + &self, + out: &mut W, + value: &Value, + paginated: bool, + is_first_page: bool, + ) -> Result<(), FormatError> { + if self.quiet { + return Ok(()); + } + let rendered = if paginated { + format_value_paginated(value, &self.format, is_first_page) + } else { + format_value(value, &self.format) + }; + writeln!(out, "{rendered}")?; + Ok(()) + } + + /// Apply the `--query` JMESPath expression to `value`. + /// + /// Returns the projected value, or the original value unchanged when no + /// query is configured. + pub fn apply_query(&self, value: &Value) -> Result { + match &self.query { + None => Ok(value.clone()), + Some(expr_str) => apply_jmespath(value, expr_str), + } + } + + /// Apply `--query` and return `None` when the projection is null. + /// + /// Used by streaming paths: events whose projection is `null` are + /// suppressed, enabling `--query` as a per-event filter. + pub fn apply_query_streaming(&self, value: &Value) -> Result, FormatError> { + match &self.query { + None => Ok(Some(value.clone())), + Some(expr_str) => { + let result = apply_jmespath(value, expr_str)?; + if result.is_null() { + Ok(None) + } else { + Ok(Some(result)) + } + } + } + } +} + +/// Resolve the default output format when no `--format` flag was passed. +/// +/// Implements steps 2–3 of the [`OutputPipeline::from_matches`] precedence: +/// - if `env_value` is a valid format string, use it; +/// - otherwise fall back to the TTY-aware default — [`OutputFormat::Table`] +/// when stdout is an interactive terminal, [`OutputFormat::Json`] when +/// piped or redirected. +/// +/// An unset or invalid `env_value` is treated identically (ignored), so a +/// stray `_OUTPUT` value never breaks the CLI. +/// +/// Pure (no IO) so it can be unit-tested by injecting `stdout_is_terminal`. +fn resolve_default_format(env_value: Option<&str>, stdout_is_terminal: bool) -> OutputFormat { + if let Some(parsed) = env_value.and_then(|v| OutputFormat::parse(v).ok()) { + return parsed; + } + if stdout_is_terminal { + OutputFormat::Table + } else { + OutputFormat::Json + } +} + +/// Supported output formats. +#[derive(Debug, Clone, PartialEq, Default)] +pub enum OutputFormat { + /// Pretty-printed JSON (default). + #[default] + Json, + /// Aligned text table. + Table, + /// YAML. + Yaml, + /// Comma-separated values. + Csv, + /// Raw server bytes — no parsing, no transformation. + Raw, + /// JSONL / NDJSON — one compact JSON value per line. + Jsonl, + /// Full HTTP response (status line + headers + body) — like `curl -i`. + Http, +} + +impl OutputFormat { + /// Parse from a string argument. + /// + /// Returns `Ok(format)` for known values, or `Err(unknown_value)` if the + /// string is not recognised. Call sites should warn the user on `Err` and + /// decide whether to fall back to JSON or surface an error. + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + "json" => Ok(Self::Json), + "table" => Ok(Self::Table), + "yaml" | "yml" => Ok(Self::Yaml), + "csv" => Ok(Self::Csv), + "raw" => Ok(Self::Raw), + "jsonl" | "ndjson" => Ok(Self::Jsonl), + "http" => Ok(Self::Http), + other => Err(other.to_string()), + } + } + + /// Parse from a string argument, falling back to JSON for unknown values. + /// + /// Prefer `parse()` at call sites where you want to surface a warning. + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + Self::parse(s).unwrap_or(Self::Json) + } +} + +/// Format a JSON value according to the specified output format. +pub fn format_value(value: &Value, format: &OutputFormat) -> String { + match format { + OutputFormat::Json => serde_json::to_string_pretty(value).unwrap_or_default(), + OutputFormat::Table => format_table(value), + OutputFormat::Yaml => format_yaml(value), + OutputFormat::Csv => format_csv(value), + // Defensive fallback; the executor normally bypasses format_value. + OutputFormat::Raw => serde_json::to_string(value).unwrap_or_default(), + OutputFormat::Jsonl => format_jsonl(value), + // Defensive fallback; the executor normally bypasses format_value for Http. + OutputFormat::Http => serde_json::to_string(value).unwrap_or_default(), + } +} + +/// Format a JSON value for a paginated page. +/// +/// When auto-paginating with `--page-all`, CSV and table formats should only +/// emit column headers on the **first** page so that each subsequent page +/// contains only data rows, making the combined output machine-parseable. +/// +/// For JSON the output is compact (one JSON object per line / NDJSON). +/// For YAML each page is prefixed with a `---` document separator so the +/// combined stream is a valid YAML multi-document file. +pub fn format_value_paginated(value: &Value, format: &OutputFormat, is_first_page: bool) -> String { + match format { + OutputFormat::Json => serde_json::to_string(value).unwrap_or_default(), + OutputFormat::Csv => format_csv_page(value, is_first_page), + OutputFormat::Table => format_table_page(value, is_first_page), + // Prefix every page with a YAML document separator so that the + // concatenated stream is parseable as a multi-document YAML file. + OutputFormat::Yaml => format!("---\n{}", format_yaml(value)), + OutputFormat::Raw => serde_json::to_string(value).unwrap_or_default(), + OutputFormat::Jsonl => format_jsonl(value), + OutputFormat::Http => serde_json::to_string(value).unwrap_or_default(), + } +} + +/// Format a JSON value as JSONL (one compact JSON line per element). +/// +/// For array values (or list-shaped API responses with an extractable data +/// array), each element is serialized as a single compact JSON line. For +/// non-array values, the entire value is serialized as one compact line. +fn format_jsonl(value: &Value) -> String { + // Try to extract a data array from a list-shaped response. + if let Some((_key, arr)) = extract_items(value) { + return format_jsonl_array(arr); + } + // Top-level array. + if let Value::Array(arr) = value { + return format_jsonl_array(arr); + } + // Single object/scalar: one compact line. + serde_json::to_string(value).unwrap_or_default() +} + +/// Serialize each element as a compact JSON line, joined by newlines. +fn format_jsonl_array(arr: &[Value]) -> String { + let mut out = String::new(); + for (i, item) in arr.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str(&serde_json::to_string(item).unwrap_or_default()); + } + out +} + +/// Extract a "data array" from a typical API list response. +/// APIs often return lists as `{ "collection": [...], "pagination": {...} }` +/// where the array key varies by resource type. +fn extract_items(value: &Value) -> Option<(&str, &Vec)> { + if let Value::Object(obj) = value { + for (key, val) in obj { + if key == "nextPageToken" || key == "kind" || key.starts_with('_') { + continue; + } + if let Value::Array(arr) = val { + if !arr.is_empty() { + return Some((key, arr)); + } + } + } + } + None +} + +fn format_table(value: &Value) -> String { + format_table_page(value, true) +} + +/// Recursively flatten a JSON object into `(dot.notation.key, string_value)` pairs. +/// +/// Nested objects become `parent.child` key names so that `--format table` can +/// render them as individual columns instead of raw JSON blobs. +fn flatten_object(obj: &serde_json::Map, prefix: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (key, val) in obj { + let full_key = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + match val { + Value::Object(nested) => { + out.extend(flatten_object(nested, &full_key)); + } + _ => { + out.push((full_key, value_to_cell(val))); + } + } + } + out +} + +/// Format as a text table, optionally omitting the header row. +/// +/// Pass `emit_header = false` for continuation pages when using `--page-all` +/// so the combined terminal output doesn't repeat column names and separator +/// lines between pages. +fn format_table_page(value: &Value, emit_header: bool) -> String { + // Try to extract a list of items from standard API response + let items = extract_items(value); + + if let Some((_key, arr)) = items { + format_array_as_table(arr, emit_header) + } else if let Value::Array(arr) = value { + format_array_as_table(arr, emit_header) + } else if let Value::Object(obj) = value { + // Single object: key/value table — flatten nested objects first + let mut output = String::new(); + let flat = flatten_object(obj, ""); + let max_key_len = flat.iter().map(|(k, _)| k.len()).max().unwrap_or(0); + for (key, val_str) in &flat { + let _ = writeln!(output, "{key:max_key_len$} {val_str}"); + } + output + } else { + value.to_string() + } +} + +fn format_array_as_table(arr: &[Value], emit_header: bool) -> String { + if arr.is_empty() { + return "(empty)\n".to_string(); + } + + // Flatten each row so nested objects become dot-notation columns. + let flat_rows: Vec> = arr + .iter() + .map(|item| match item { + Value::Object(obj) => flatten_object(obj, ""), + _ => vec![(String::new(), value_to_cell(item))], + }) + .collect(); + + // Collect all unique column names (preserving insertion order). + let mut columns: Vec = Vec::new(); + for row in &flat_rows { + for (key, _) in row { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + + if columns.is_empty() { + // Array of non-objects + let mut output = String::new(); + for item in arr { + let _ = writeln!(output, "{}", value_to_cell(item)); + } + return output; + } + + // Build lookup: row_index -> column_name -> cell_value + let row_maps: Vec> = flat_rows + .iter() + .map(|pairs| { + pairs + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect() + }) + .collect(); + + // Calculate column widths (char-count, not byte-count). + let mut widths: Vec = columns.iter().map(|c| c.chars().count()).collect(); + let rows: Vec> = row_maps + .iter() + .map(|row| { + columns + .iter() + .enumerate() + .map(|(i, col)| { + let cell = row.get(col.as_str()).copied().unwrap_or("").to_string(); + let char_len = cell.chars().count(); + if char_len > widths[i] { + widths[i] = char_len; + } + // Cap column width at 60 chars + if widths[i] > 60 { + widths[i] = 60; + } + cell + }) + .collect() + }) + .collect(); + + let mut output = String::new(); + + if emit_header { + // Header + let header: Vec = columns + .iter() + .enumerate() + .map(|(i, c)| format!("{:width$}", c, width = widths[i])) + .collect(); + let _ = writeln!(output, "{}", header.join(" ")); + + // Separator + let sep: Vec = widths.iter().map(|w| "─".repeat(*w)).collect(); + let _ = writeln!(output, "{}", sep.join(" ")); + } + + // Rows — truncate by char count to avoid panicking on multi-byte UTF-8. + for row in &rows { + let cells: Vec = row + .iter() + .enumerate() + .map(|(i, c)| { + let char_len = c.chars().count(); + let truncated = if char_len > widths[i] { + // Safe char-boundary slice: take widths[i]-1 chars, then append ellipsis. + let truncated_str: String = c.chars().take(widths[i] - 1).collect(); + format!("{truncated_str}…") + } else { + c.clone() + }; + // Pad to column width (by char count) + let pad = widths[i].saturating_sub(truncated.chars().count()); + format!("{truncated}{}", " ".repeat(pad)) + }) + .collect(); + let _ = writeln!(output, "{}", cells.join(" ")); + } + + output +} + +fn format_yaml(value: &Value) -> String { + let raw = json_to_yaml(value, 0); + // The recursive serialiser prepends `\n` before each key/item so that + // nested levels compose cleanly. At the top level we strip the leading + // newline so the final output starts with content, not a blank line. + raw.strip_prefix('\n').unwrap_or(&raw).to_string() +} + +fn json_to_yaml(value: &Value, indent: usize) -> String { + let prefix = " ".repeat(indent); + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::String(s) => { + if s.contains('\n') { + // Genuine multi-line content: block scalar is the most readable choice. + format!( + "|\n{}", + s.lines() + .map(|l| format!("{prefix} {l}")) + .collect::>() + .join("\n") + ) + } else { + // Single-line strings: always double-quote so that characters like + // `#` (comment marker) and `:` (mapping indicator) are never + // misinterpreted by YAML parsers. Escape backslashes and double + // quotes to keep the output valid. + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") + } + } + Value::Array(arr) => { + if arr.is_empty() { + return "[]".to_string(); + } + let mut out = String::new(); + let inner_prefix = " ".repeat(indent + 1); + for item in arr { + let val_str = json_to_yaml(item, indent + 1); + // Object/array values start with `\n` + indent; strip both so + // the first key lands on the same line as the dash (standard + // YAML block-sequence style). Subsequent lines keep their full + // indent, which aligns them with the first key. + let inline = val_str + .strip_prefix('\n') + .and_then(|s| s.strip_prefix(inner_prefix.as_str())) + .unwrap_or(&val_str); + let _ = write!(out, "\n{prefix}- {inline}"); + } + out + } + Value::Object(obj) => { + if obj.is_empty() { + return "{}".to_string(); + } + let mut out = String::new(); + for (key, val) in obj { + match val { + Value::Object(_) | Value::Array(_) => { + let val_str = json_to_yaml(val, indent + 1); + if val_str.starts_with('\n') { + // Multi-line: colon immediately before the newline + let _ = write!(out, "\n{prefix}{key}:{val_str}"); + } else { + // Single-line (empty collection): standard space + let _ = write!(out, "\n{prefix}{key}: {val_str}"); + } + } + _ => { + let val_str = json_to_yaml(val, indent); + let _ = write!(out, "\n{prefix}{key}: {val_str}"); + } + } + } + out + } + } +} + +fn format_csv(value: &Value) -> String { + format_csv_page(value, true) +} + +/// Format as CSV, optionally omitting the header row. +/// +/// Pass `emit_header = false` for all pages after the first when using +/// `--page-all`, so the combined output has a single header line. +fn format_csv_page(value: &Value, emit_header: bool) -> String { + let items = extract_items(value); + + let arr = if let Some((_key, arr)) = items { + arr.as_slice() + } else if let Value::Array(arr) = value { + arr.as_slice() + } else { + // Single value — just output it + return value_to_cell(value); + }; + + if arr.is_empty() { + return String::new(); + } + + // Array of non-objects + if !arr.iter().any(|v| v.is_object()) { + let mut output = String::new(); + for item in arr { + if let Value::Array(inner) = item { + let cells: Vec = inner + .iter() + .map(|v| csv_escape(&value_to_cell(v))) + .collect(); + let _ = writeln!(output, "{}", cells.join(",")); + } else { + let _ = writeln!(output, "{}", csv_escape(&value_to_cell(item))); + } + } + return output; + } + + // Collect columns + let mut columns: Vec = Vec::new(); + for item in arr { + if let Value::Object(obj) = item { + for key in obj.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + } + + let mut output = String::new(); + + // Header (omitted on continuation pages) + if emit_header { + let _ = writeln!(output, "{}", columns.join(",")); + } + + // Rows + for item in arr { + let cells: Vec = columns + .iter() + .map(|col| { + if let Value::Object(obj) = item { + csv_escape(&value_to_cell(obj.get(col).unwrap_or(&Value::Null))) + } else { + String::new() + } + }) + .collect(); + let _ = writeln!(output, "{}", cells.join(",")); + } + + output +} + +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +fn value_to_cell(value: &Value) -> String { + match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Array(arr) => { + let items: Vec = arr.iter().map(value_to_cell).collect(); + items.join(", ") + } + Value::Object(_) => serde_json::to_string(value).unwrap_or_default(), + } +} + +/// Apply a JMESPath expression to a `serde_json::Value`. +/// +/// Converts the value to the `jmespath::Variable` domain, searches, then +/// converts the result back to `serde_json::Value`. Returns `Value::Null` +/// when the expression does not match anything in the input. +pub(crate) fn apply_jmespath(value: &Value, expr_str: &str) -> Result { + let expr = jmespath::compile(expr_str) + .map_err(|e| FormatError::InvalidQuery(e.to_string()))?; + // Convert serde_json::Value → JSON string → jmespath::Variable. + let json_str = + serde_json::to_string(value).map_err(|e| FormatError::QueryEvaluation(e.to_string()))?; + let data = jmespath::Variable::from_json(&json_str) + .map_err(|e| FormatError::QueryEvaluation(e.to_string()))?; + let result = expr + .search(data) + .map_err(|e| FormatError::QueryEvaluation(e.to_string()))?; + // Convert jmespath::Variable → JSON string → serde_json::Value. + let result_json = + serde_json::to_string(&*result).map_err(|e| FormatError::QueryEvaluation(e.to_string()))?; + serde_json::from_str(&result_json).map_err(|e| FormatError::QueryEvaluation(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_output_format_from_str() { + assert_eq!(OutputFormat::from_str("json"), OutputFormat::Json); + assert_eq!(OutputFormat::from_str("table"), OutputFormat::Table); + assert_eq!(OutputFormat::from_str("yaml"), OutputFormat::Yaml); + assert_eq!(OutputFormat::from_str("yml"), OutputFormat::Yaml); + assert_eq!(OutputFormat::from_str("csv"), OutputFormat::Csv); + assert_eq!(OutputFormat::from_str("unknown"), OutputFormat::Json); + } + + #[test] + fn test_output_format_parse_known() { + assert_eq!(OutputFormat::parse("json"), Ok(OutputFormat::Json)); + assert_eq!(OutputFormat::parse("table"), Ok(OutputFormat::Table)); + assert_eq!(OutputFormat::parse("yaml"), Ok(OutputFormat::Yaml)); + assert_eq!(OutputFormat::parse("yml"), Ok(OutputFormat::Yaml)); + assert_eq!(OutputFormat::parse("csv"), Ok(OutputFormat::Csv)); + // Case-insensitive + assert_eq!(OutputFormat::parse("JSON"), Ok(OutputFormat::Json)); + assert_eq!(OutputFormat::parse("TABLE"), Ok(OutputFormat::Table)); + } + + #[test] + fn test_output_format_parse_unknown_returns_err() { + assert!(OutputFormat::parse("bogus").is_err()); + assert_eq!(OutputFormat::parse("bogus").unwrap_err(), "bogus"); + assert!(OutputFormat::parse("").is_err()); + } + + #[test] + fn test_format_json() { + let val = json!({"name": "test"}); + let output = format_value(&val, &OutputFormat::Json); + assert!(output.contains("\"name\"")); + assert!(output.contains("\"test\"")); + } + + #[test] + fn test_format_table_array_of_objects() { + let val = json!({ + "files": [ + {"id": "1", "name": "hello.txt"}, + {"id": "2", "name": "world.txt"} + ] + }); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("id")); + assert!(output.contains("name")); + assert!(output.contains("hello.txt")); + assert!(output.contains("world.txt")); + // Check separator line + assert!(output.contains("──")); + } + + #[test] + fn test_format_table_single_object() { + let val = json!({"id": "abc", "name": "test"}); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("id")); + assert!(output.contains("abc")); + } + + #[test] + fn test_format_table_nested_object_flattened() { + // Nested objects should become dot-notation columns, not raw JSON blobs. + let val = json!({ + "user": { + "displayName": "Alice", + "emailAddress": "alice@example.com" + }, + "storageQuota": { + "limit": "1000", + "usage": "500" + } + }); + let output = format_value(&val, &OutputFormat::Table); + // Should contain dot-notation keys + assert!( + output.contains("user.displayName"), + "expected flattened key in output:\n{output}" + ); + assert!( + output.contains("user.emailAddress"), + "expected flattened key in output:\n{output}" + ); + assert!( + output.contains("Alice"), + "expected value in output:\n{output}" + ); + // Should NOT contain raw JSON blobs + assert!( + !output.contains("{\"displayName"), + "should not have raw JSON blob:\n{output}" + ); + } + + #[test] + fn test_format_table_nested_objects_in_array() { + let val = json!([ + {"id": "1", "owner": {"name": "Alice"}}, + {"id": "2", "owner": {"name": "Bob"}} + ]); + let output = format_value(&val, &OutputFormat::Table); + assert!( + output.contains("owner.name"), + "expected flattened column:\n{output}" + ); + assert!(output.contains("Alice"), "expected value:\n{output}"); + assert!(output.contains("Bob"), "expected value:\n{output}"); + } + + #[test] + fn test_format_table_multibyte_truncation_does_not_panic() { + // Column width cap is 60 chars, so a long string with multi-byte chars + // must be safely truncated without a byte-boundary panic. + let long_emoji = "😀".repeat(70); // each emoji is 4 bytes + let val = json!([{"col": long_emoji}]); + // Should not panic + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("col"), "column name must appear:\n{output}"); + } + + #[test] + fn test_format_table_multibyte_exact_boundary() { + // Multi-byte chars at various positions must not panic or produce garbled output. + let val = json!([{"name": "café résumé naïve"}]); + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("name"), "column must appear:\n{output}"); + } + + #[test] + fn test_format_csv() { + let val = json!({ + "files": [ + {"id": "1", "name": "hello"}, + {"id": "2", "name": "world"} + ] + }); + let output = format_value(&val, &OutputFormat::Csv); + assert!(output.contains("id,name")); + assert!(output.contains("1,hello")); + assert!(output.contains("2,world")); + } + + #[test] + fn test_format_csv_array_of_arrays() { + // Sheets API returns {"values": [["col1","col2"], ["a","b"]]} + let val = json!({ + "values": [ + ["Student Name", "Gender", "Class Level"], + ["Alexandra", "Female", "4. Senior"], + ["Andrew", "Male", "1. Freshman"] + ] + }); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "Student Name,Gender,Class Level"); + assert_eq!(lines[1], "Alexandra,Female,4. Senior"); + assert_eq!(lines[2], "Andrew,Male,1. Freshman"); + } + + #[test] + fn test_format_csv_flat_scalars() { + // Flat array of non-object, non-array values → one value per line + let val = json!(["apple", "banana", "cherry"]); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "apple"); + assert_eq!(lines[1], "banana"); + assert_eq!(lines[2], "cherry"); + } + + #[test] + fn test_format_csv_flat_scalars_with_escaping() { + // Scalars that contain commas/quotes must be CSV-escaped + let val = json!(["plain", "has,comma", "has\"quote"]); + let output = format_value(&val, &OutputFormat::Csv); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0], "plain"); + assert_eq!(lines[1], "\"has,comma\""); + assert_eq!(lines[2], "\"has\"\"quote\""); + } + + #[test] + fn test_format_csv_escape() { + assert_eq!(csv_escape("simple"), "simple"); + assert_eq!(csv_escape("has,comma"), "\"has,comma\""); + assert_eq!(csv_escape("has\"quote"), "\"has\"\"quote\""); + } + + #[test] + fn test_format_yaml() { + let val = json!({"name": "test", "count": 42}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!(output.contains("name: \"test\"")); + assert!(output.contains("count: 42")); + } + + #[test] + fn test_format_table_empty_array() { + let val = json!({"files": []}); + // No items to extract, falls back to single-object table + let output = format_value(&val, &OutputFormat::Table); + assert!(output.contains("files")); + } + + #[test] + fn test_extract_items() { + let val = json!({"files": [{"id": "1"}], "nextPageToken": "abc"}); + let (key, items) = extract_items(&val).unwrap(); + assert_eq!(key, "files"); + assert_eq!(items.len(), 1); + } + + #[test] + fn test_extract_items_none() { + let val = json!({"status": "ok"}); + assert!(extract_items(&val).is_none()); + } + + // --- YAML block-scalar regression tests --- + + #[test] + fn test_format_yaml_hash_in_string_is_quoted_not_block() { + // `drive#file` contains `#` which is a YAML comment marker; the + // serialiser must quote it rather than emit a block scalar. + let val = json!({"kind": "drive#file", "id": "123"}); + let output = format_value(&val, &OutputFormat::Yaml); + // Must be a double-quoted string, not a block scalar (`|`). + assert!( + output.contains("kind: \"drive#file\""), + "expected double-quoted kind, got:\n{output}" + ); + assert!( + !output.contains("kind: |"), + "kind must not use block scalar, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_colon_in_string_is_quoted() { + let val = json!({"url": "https://example.com/path"}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("url: \"https://example.com/path\""), + "expected double-quoted url, got:\n{output}" + ); + assert!(!output.contains("url: |"), "url must not use block scalar"); + } + + #[test] + fn test_format_yaml_multiline_still_uses_block() { + let val = json!({"body": "line one\nline two"}); + let output = format_value(&val, &OutputFormat::Yaml); + // Multi-line content should still use block scalar. + assert!( + output.contains("body: |"), + "multiline string must use block scalar, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_no_leading_blank_line() { + let val = json!({"name": "test", "count": 42}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + !output.starts_with('\n'), + "YAML output must not start with a blank line, got:\n{output}" + ); + assert!( + output.starts_with("name:") || output.starts_with("count:"), + "YAML output must start with a key, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_empty_array_has_space() { + let val = json!({"items": [], "name": "test"}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("items: []"), + "empty array must have a space after colon, got:\n{output}" + ); + assert!( + !output.contains("items:[]"), + "must not produce 'key:[]' without space, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_empty_object_has_space() { + let val = json!({"metadata": {}, "id": "1"}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("metadata: {}"), + "empty object must have a space after colon, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_nested_object() { + let val = json!({"user": {"name": "Alice", "age": 30}}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("user:"), + "nested object key must appear, got:\n{output}" + ); + assert!( + output.contains(" name: \"Alice\""), + "nested key must be indented, got:\n{output}" + ); + assert!( + output.contains(" age: 30"), + "nested numeric value must be indented, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_nested_array() { + let val = json!({"tags": ["alpha", "beta"]}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("tags:"), + "array key must appear, got:\n{output}" + ); + assert!( + output.contains("- \"alpha\""), + "array items must use dash notation, got:\n{output}" + ); + assert!( + output.contains("- \"beta\""), + "array items must use dash notation, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_array_of_objects() { + let val = json!([ + {"id": "1", "name": "foo"}, + {"id": "2", "name": "bar"} + ]); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.contains("- id: \"1\""), + "array items must use dash + key, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_top_level_array() { + let val = json!(["one", "two", "three"]); + let output = format_value(&val, &OutputFormat::Yaml); + assert!( + output.starts_with("- \"one\""), + "top-level array must start with dash, got:\n{output}" + ); + } + + #[test] + fn test_format_yaml_null_bool_number() { + let val = json!({"n": null, "b": true, "i": 42, "f": 3.14}); + let output = format_value(&val, &OutputFormat::Yaml); + assert!(output.contains("n: null"), "null, got:\n{output}"); + assert!(output.contains("b: true"), "bool, got:\n{output}"); + assert!(output.contains("i: 42"), "int, got:\n{output}"); + assert!(output.contains("f: 3.14"), "float, got:\n{output}"); + } + + // --- Paginated format tests --- + + #[test] + fn test_format_value_paginated_csv_first_page_has_header() { + let val = json!({ + "files": [ + {"id": "1", "name": "a.txt"}, + {"id": "2", "name": "b.txt"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Csv, true); + let lines: Vec<&str> = output.lines().collect(); + assert_eq!(lines[0], "id,name", "first page must start with header"); + assert_eq!(lines[1], "1,a.txt"); + } + + #[test] + fn test_format_value_paginated_csv_continuation_no_header() { + let val = json!({ + "files": [ + {"id": "3", "name": "c.txt"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Csv, false); + let lines: Vec<&str> = output.lines().collect(); + // The first (and only) line must be a data row, not the header. + assert_eq!(lines[0], "3,c.txt", "continuation page must have no header"); + assert!( + !output.contains("id,name"), + "header must be absent on continuation pages" + ); + } + + #[test] + fn test_format_value_paginated_table_first_page_has_header() { + let val = json!({ + "items": [ + {"id": "1", "name": "foo"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Table, true); + assert!( + output.contains("id"), + "table header must appear on first page" + ); + assert!(output.contains("──"), "separator must appear on first page"); + } + + #[test] + fn test_format_value_paginated_table_continuation_no_header() { + let val = json!({ + "items": [ + {"id": "2", "name": "bar"} + ] + }); + let output = format_value_paginated(&val, &OutputFormat::Table, false); + assert!(output.contains("bar"), "data row must be present"); + assert!( + !output.contains("──"), + "separator must be absent on continuation pages" + ); + } + + #[test] + fn test_format_value_paginated_yaml_has_document_separator() { + let val = json!({"files": [{"id": "1", "name": "foo"}]}); + let first = format_value_paginated(&val, &OutputFormat::Yaml, true); + let second = format_value_paginated(&val, &OutputFormat::Yaml, false); + assert!( + first.starts_with("---\n"), + "first YAML page must start with ---" + ); + assert!( + second.starts_with("---\n"), + "continuation YAML pages must also start with ---" + ); + } + + // ----------------------------------------------------------------------- + // OutputPipeline (Step 1: abstraction only — format + color_mode) + // ----------------------------------------------------------------------- + + fn matches_for(args: &[&str]) -> clap::ArgMatches { + clap::Command::new("test") + .arg( + clap::Arg::new("format") + .long("format") + .value_name("FORMAT"), + ) + .try_get_matches_from(args) + .expect("clap parse should succeed in tests") + } + + #[test] + fn pipeline_from_matches_reads_explicit_format() { + // An explicit `--format` flag is honored regardless of TTY / env. + let matches = matches_for(&["test", "--format", "yaml"]); + let pipeline = OutputPipeline::from_matches(&matches, "test").unwrap(); + assert_eq!(pipeline.format, OutputFormat::Yaml); + assert_eq!(pipeline.color_mode, ColorMode::Auto); + } + + #[test] + fn pipeline_from_matches_rejects_unknown_format() { + let matches = matches_for(&["test", "--format", "garbage"]); + let err = OutputPipeline::from_matches(&matches, "test").unwrap_err(); + assert!( + matches!(err, FormatError::UnknownFormat(ref s) if s == "garbage"), + "expected UnknownFormat, got: {err:?}", + ); + } + + // ----------------------------------------------------------------------- + // Default-format precedence: flag > _OUTPUT env > TTY-aware default + // ----------------------------------------------------------------------- + + #[test] + fn resolve_default_format_no_env_piped_is_json() { + // No env override + non-terminal stdout (piped/redirected) → JSON. + assert_eq!(resolve_default_format(None, false), OutputFormat::Json); + } + + #[test] + fn resolve_default_format_no_env_terminal_is_table() { + // No env override + interactive terminal → table. + assert_eq!(resolve_default_format(None, true), OutputFormat::Table); + } + + #[test] + fn resolve_default_format_valid_env_wins_over_tty_default() { + // A valid env value beats the TTY-aware default in both directions. + assert_eq!( + resolve_default_format(Some("yaml"), true), + OutputFormat::Yaml, + ); + assert_eq!( + resolve_default_format(Some("csv"), false), + OutputFormat::Csv, + ); + } + + #[test] + fn resolve_default_format_env_is_case_insensitive() { + assert_eq!( + resolve_default_format(Some("TABLE"), false), + OutputFormat::Table, + ); + } + + #[test] + fn resolve_default_format_invalid_env_falls_back_to_tty_default() { + // A bogus env value is ignored — the TTY-aware default applies. + assert_eq!( + resolve_default_format(Some("garbage"), false), + OutputFormat::Json, + ); + assert_eq!( + resolve_default_format(Some("garbage"), true), + OutputFormat::Table, + ); + } + + #[test] + fn resolve_default_format_empty_env_falls_back_to_tty_default() { + // An empty env value parses as unknown → TTY default. + assert_eq!(resolve_default_format(Some(""), false), OutputFormat::Json); + } + + #[test] + fn pipeline_from_matches_explicit_flag_beats_env() { + // Flag (step 1) wins even when _OUTPUT (step 2) is set. + std::env::set_var("FMTTEST_FLAGWINS_OUTPUT", "csv"); + let matches = matches_for(&["test", "--format", "yaml"]); + let pipeline = OutputPipeline::from_matches(&matches, "fmttest-flagwins").unwrap(); + std::env::remove_var("FMTTEST_FLAGWINS_OUTPUT"); + assert_eq!(pipeline.format, OutputFormat::Yaml); + } + + #[test] + fn pipeline_from_matches_env_var_name_mirrors_logging_convention() { + // `_OUTPUT` uppercases the binary name and maps `-` → `_`, + // matching the `_LOG` convention. No flag → env is consulted. + std::env::set_var("MY_CLI_OUTPUT", "yaml"); + let matches = matches_for(&["test"]); + let pipeline = OutputPipeline::from_matches(&matches, "my-cli").unwrap(); + std::env::remove_var("MY_CLI_OUTPUT"); + assert_eq!(pipeline.format, OutputFormat::Yaml); + } + + #[test] + fn pipeline_emit_single_page_json_is_pretty_with_trailing_newline() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + let val = json!({"name": "test", "n": 1}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // pretty JSON spans multiple lines + assert!(s.contains("\"name\": \"test\""), "expected pretty JSON, got: {s}"); + assert!(s.contains('\n'), "expected indented (multi-line) JSON"); + assert!(s.ends_with('\n'), "expected trailing newline"); + } + + #[test] + fn pipeline_emit_paginated_json_is_compact_one_line() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + let val = json!({"name": "test", "n": 1}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, true, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // compact form: exactly one newline (the trailing one); no pretty + // indentation; suitable for NDJSON. + let body = s.strip_suffix('\n').expect("trailing newline"); + assert!(!body.contains('\n'), "expected single-line NDJSON, got: {s}"); + assert!(!body.contains(" "), "expected no indentation, got: {s}"); + assert!(body.contains("\"name\":\"test\""), "expected compact JSON, got: {s}"); + } + + #[test] + fn pipeline_emit_quiet_suppresses_output() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: true, + query: None, + }; + let val = json!({"name": "test"}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + assert!(buf.is_empty(), "quiet mode should suppress all output"); + } + + #[test] + fn apply_jmespath_extracts_nested_field() { + let val = json!({"foo": {"bar": "hello"}}); + let result = apply_jmespath(&val, "foo.bar").unwrap(); + assert_eq!(result, json!("hello")); + } + + #[test] + fn apply_jmespath_returns_null_for_missing_path() { + let val = json!({"foo": "bar"}); + let result = apply_jmespath(&val, "nonexistent").unwrap(); + assert_eq!(result, Value::Null); + } + + #[test] + fn apply_jmespath_array_filter() { + let val = json!({"items": [{"name": "a", "active": true}, {"name": "b", "active": false}]}); + let result = apply_jmespath(&val, "items[?active].name").unwrap(); + assert_eq!(result, json!(["a"])); + } + + #[test] + fn apply_jmespath_invalid_expression() { + let val = json!({}); + let result = apply_jmespath(&val, "["); + assert!(result.is_err()); + } + + #[test] + fn pipeline_emit_with_query_projects_value() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + query: Some("name".to_string()), + }; + let val = json!({"name": "test", "extra": 123}); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + assert!(s.contains("\"test\""), "expected projected value, got: {s}"); + assert!(!s.contains("extra"), "should not contain non-projected fields"); + } + + #[test] + fn pipeline_apply_query_streaming_suppresses_null() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + query: Some("nonexistent".to_string()), + }; + let val = json!({"foo": "bar"}); + let result = pipeline.apply_query_streaming(&val).unwrap(); + assert!(result.is_none(), "null projection should be suppressed in streaming"); + } + + #[test] + fn pipeline_apply_query_streaming_passes_non_null() { + let pipeline = OutputPipeline { + format: OutputFormat::Json, + color_mode: ColorMode::Never, + quiet: false, + query: Some("foo".to_string()), + }; + let val = json!({"foo": "bar"}); + let result = pipeline.apply_query_streaming(&val).unwrap(); + assert_eq!(result, Some(json!("bar"))); + } + + // ----------------------------------------------------------------------- + // Raw format + // ----------------------------------------------------------------------- + + #[test] + fn parse_raw_format() { + assert_eq!(OutputFormat::parse("raw"), Ok(OutputFormat::Raw)); + assert_eq!(OutputFormat::parse("RAW"), Ok(OutputFormat::Raw)); + assert_eq!(OutputFormat::parse("Raw"), Ok(OutputFormat::Raw)); + } + + #[test] + fn is_raw_returns_true_for_raw_format() { + let pipeline = OutputPipeline { + format: OutputFormat::Raw, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + assert!(pipeline.is_raw()); + } + + #[test] + fn is_raw_returns_false_for_other_formats() { + for fmt in [OutputFormat::Json, OutputFormat::Table, OutputFormat::Yaml, OutputFormat::Csv, OutputFormat::Jsonl, OutputFormat::Http] { + let pipeline = OutputPipeline { + format: fmt, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + assert!(!pipeline.is_raw()); + } + } + + #[test] + fn resolve_default_format_env_raw() { + assert_eq!(resolve_default_format(Some("raw"), false), OutputFormat::Raw); + } + + #[test] + fn pipeline_from_matches_explicit_raw_flag() { + let matches = matches_for(&["test", "--format", "raw"]); + let pipeline = OutputPipeline::from_matches(&matches, "test").unwrap(); + assert_eq!(pipeline.format, OutputFormat::Raw); + } + + #[test] + fn format_value_raw_fallback_is_compact_json() { + let val = json!({"name": "test", "n": 1}); + let out = format_value(&val, &OutputFormat::Raw); + assert!(!out.contains('\n'), "raw fallback should be compact JSON"); + assert!(out.contains("\"name\":\"test\"")); + } + + #[test] + fn format_value_paginated_raw_fallback_is_compact_json() { + let val = json!({"items": [1, 2]}); + let first = format_value_paginated(&val, &OutputFormat::Raw, true); + let second = format_value_paginated(&val, &OutputFormat::Raw, false); + assert!(!first.contains('\n')); + assert_eq!(first, second, "raw paginated fallback ignores is_first_page"); + } + + // ----------------------------------------------------------------------- + // JSONL format + // ----------------------------------------------------------------------- + + #[test] + fn parse_jsonl_format() { + assert_eq!(OutputFormat::parse("jsonl"), Ok(OutputFormat::Jsonl)); + assert_eq!(OutputFormat::parse("JSONL"), Ok(OutputFormat::Jsonl)); + assert_eq!(OutputFormat::parse("Jsonl"), Ok(OutputFormat::Jsonl)); + assert_eq!(OutputFormat::parse("ndjson"), Ok(OutputFormat::Jsonl)); + assert_eq!(OutputFormat::parse("NDJSON"), Ok(OutputFormat::Jsonl)); + } + + #[test] + fn jsonl_single_object_is_compact_one_line() { + let val = json!({"name": "test", "n": 1}); + let out = format_value(&val, &OutputFormat::Jsonl); + assert!(!out.contains('\n'), "single object should be one line, got: {out}"); + assert!(out.contains("\"name\":\"test\""), "should be compact JSON"); + } + + #[test] + fn jsonl_top_level_array_flattened() { + let val = json!([{"id": 1}, {"id": 2}, {"id": 3}]); + let out = format_value(&val, &OutputFormat::Jsonl); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 3, "each array element on its own line, got: {out}"); + assert_eq!(lines[0], r#"{"id":1}"#); + assert_eq!(lines[1], r#"{"id":2}"#); + assert_eq!(lines[2], r#"{"id":3}"#); + } + + #[test] + fn jsonl_list_response_extracts_and_flattens_data_array() { + let val = json!({ + "items": [{"id": "a"}, {"id": "b"}], + "nextPageToken": "abc" + }); + let out = format_value(&val, &OutputFormat::Jsonl); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 2, "should flatten extracted data array, got: {out}"); + assert_eq!(lines[0], r#"{"id":"a"}"#); + assert_eq!(lines[1], r#"{"id":"b"}"#); + } + + #[test] + fn jsonl_empty_array_is_empty_string() { + let val = json!([]); + let out = format_value(&val, &OutputFormat::Jsonl); + assert!(out.is_empty(), "empty array should produce empty string, got: {out}"); + } + + #[test] + fn jsonl_scalar_value() { + let val = json!(42); + let out = format_value(&val, &OutputFormat::Jsonl); + assert_eq!(out, "42"); + } + + #[test] + fn jsonl_paginated_flattens_array() { + let val = json!({"events": [{"id": 1}, {"id": 2}]}); + let first = format_value_paginated(&val, &OutputFormat::Jsonl, true); + let second = format_value_paginated(&val, &OutputFormat::Jsonl, false); + let lines: Vec<&str> = first.lines().collect(); + assert_eq!(lines.len(), 2); + assert_eq!(first, second, "jsonl paginated ignores is_first_page"); + } + + #[test] + fn resolve_default_format_env_jsonl() { + assert_eq!(resolve_default_format(Some("jsonl"), false), OutputFormat::Jsonl); + } + + #[test] + fn pipeline_from_matches_explicit_jsonl_flag() { + let matches = matches_for(&["test", "--format", "jsonl"]); + let pipeline = OutputPipeline::from_matches(&matches, "test").unwrap(); + assert_eq!(pipeline.format, OutputFormat::Jsonl); + } + + #[test] + fn pipeline_emit_jsonl_flattens_array() { + let pipeline = OutputPipeline { + format: OutputFormat::Jsonl, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + let val = json!([{"a": 1}, {"a": 2}]); + let mut buf: Vec = Vec::new(); + pipeline.emit(&mut buf, &val, false, true).unwrap(); + let s = String::from_utf8(buf).unwrap(); + let lines: Vec<&str> = s.trim_end().lines().collect(); + assert_eq!(lines.len(), 2, "emit should flatten array to JSONL, got: {s}"); + } + + // ----------------------------------------------------------------------- + // HTTP format + // ----------------------------------------------------------------------- + + #[test] + fn parse_http_format() { + assert_eq!(OutputFormat::parse("http"), Ok(OutputFormat::Http)); + assert_eq!(OutputFormat::parse("HTTP"), Ok(OutputFormat::Http)); + assert_eq!(OutputFormat::parse("Http"), Ok(OutputFormat::Http)); + } + + #[test] + fn is_http_returns_true_for_http_format() { + let pipeline = OutputPipeline { + format: OutputFormat::Http, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + assert!(pipeline.is_http()); + } + + #[test] + fn is_http_returns_false_for_other_formats() { + for fmt in [OutputFormat::Json, OutputFormat::Table, OutputFormat::Yaml, OutputFormat::Csv, OutputFormat::Raw, OutputFormat::Jsonl] { + let pipeline = OutputPipeline { + format: fmt, + color_mode: ColorMode::Never, + quiet: false, + query: None, + }; + assert!(!pipeline.is_http()); + } + } + + #[test] + fn resolve_default_format_env_http() { + assert_eq!(resolve_default_format(Some("http"), false), OutputFormat::Http); + } + + #[test] + fn pipeline_from_matches_explicit_http_flag() { + let matches = matches_for(&["test", "--format", "http"]); + let pipeline = OutputPipeline::from_matches(&matches, "test").unwrap(); + assert_eq!(pipeline.format, OutputFormat::Http); + } + + #[test] + fn format_value_http_fallback_is_compact_json() { + let val = json!({"name": "test", "n": 1}); + let out = format_value(&val, &OutputFormat::Http); + assert!(!out.contains('\n'), "http fallback should be compact JSON"); + assert!(out.contains("\"name\":\"test\"")); + } + + #[test] + fn format_value_paginated_http_fallback_is_compact_json() { + let val = json!({"items": [1, 2]}); + let first = format_value_paginated(&val, &OutputFormat::Http, true); + let second = format_value_paginated(&val, &OutputFormat::Http, false); + assert!(!first.contains('\n')); + assert_eq!(first, second, "http paginated fallback ignores is_first_page"); + } +} diff --git a/src/graphql/app.rs b/src/graphql/app.rs new file mode 100644 index 0000000..4c364ef --- /dev/null +++ b/src/graphql/app.rs @@ -0,0 +1,511 @@ +//! High-level API for building CLIs from GraphQL schemas. +//! +//! [`CliApp`] provides a builder-style API that lets consumers create a +//! fully-functional CLI in just a few lines. [`AppContext`] exposes the +//! loaded spec and executor so that custom command handlers can call the +//! API programmatically. + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; +use crate::error::CliError; +use crate::formatter; +use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLOperation as RestMethod}; +use crate::graphql::executor; + +/// Builder for a schema-driven CLI application (GraphQL). +pub struct CliApp { + pub(crate) name: String, + pub(crate) spec_json: Option, + pub(crate) endpoint_url: Option, + /// Auth bindings; mirrors the OpenAPI variant. GraphQL introspection + /// JSON doesn't carry per-operation security metadata, so the + /// constructed provider is `Any` by default — generators can flip + /// [`auth_strategy`](Self::auth_strategy) to `All` for APIs that + /// require multiple schemes simultaneously. + pub(crate) auth_bindings: Vec<(String, SchemeBinding)>, + auth_strategy: AuthStrategy, + /// Trust roots parsed at builder-call time. Storing parsed certs (not + /// raw bytes) means the validation error message lives in one place + /// — at the call site of `extra_root_cert`, where it's most useful. + pub(crate) extra_root_certs: Vec, + /// Raw PEM bytes for each trust root added via `extra_root_cert`, kept + /// alongside the parsed `extra_root_certs` above. Threaded through to + /// `HttpConfig::with_parsed_root_certs` so transport-neutral callers + /// (`HttpConfig::resolve`) can hand PEM to non-reqwest TLS connectors. + pub(crate) extra_root_certs_pem: Vec>, +} + +#[allow(dead_code)] // Methods available for binding wrappers to delegate to. +impl CliApp { + /// Create a new CLI application with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + spec_json: None, + endpoint_url: None, + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + } + } + + /// Set the GraphQL introspection JSON schema string. Typically used with `include_str!`. + pub fn spec(mut self, json: &str) -> Self { + self.spec_json = Some(json.to_string()); + self + } + + /// Set the GraphQL endpoint URL. + pub fn endpoint(mut self, url: &str) -> Self { + self.endpoint_url = Some(url.to_string()); + self + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::from_env(env))`. + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::cli(arg_name))`. + /// Auto-registers a global `--` flag at run time. + pub fn auth_scheme_cli(self, scheme_name: &str, arg_name: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::cli(arg_name)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::file(path))`. + pub fn auth_scheme_file(self, scheme_name: &str, path: impl AsRef) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::file(path)) + } + + /// Bind a credential source to a named auth scheme. See + /// [`crate::openapi::CliApp::auth_scheme`] for the OpenAPI version's + /// detailed semantics — the GraphQL variant differs only in that there + /// is no spec-declared scheme metadata, so single-value bindings always + /// produce an `Authorization: Bearer ` provider. + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to a basic-auth scheme. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Plug in a fully-custom [`AuthProvider`][crate::auth::AuthProvider] for + /// a scheme name. Wraps the provider in [`Arc`] internally; use + /// [`auth_provider_shared`](Self::auth_provider_shared) if you already + /// have a `DynAuthProvider`. + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, std::sync::Arc::new(provider)) + } + + /// Variant of [`auth_provider`](Self::auth_provider) that takes an + /// already-built [`DynAuthProvider`]. + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Custom(provider), + )); + self + } + + /// Pin how the bound auth schemes compose. See + /// [`crate::openapi::CliApp::auth_strategy`] for details. GraphQL has + /// no per-endpoint security metadata, so [`AuthStrategy::Routing`] + /// degenerates to `Any` here. + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.auth_strategy = strategy; + self + } + + /// Register an extra trust root that this CLI will accept on top of the + /// system's default roots. `pem` must be a PEM-encoded certificate (or + /// concatenated PEM bundle), typically loaded with `include_bytes!`. + /// + /// Useful for distributing a CLI inside an organization where every + /// machine should trust the company's internal CA out of the box, without + /// asking each user to set `_CA_BUNDLE`. + /// + /// ```ignore + /// # // ignored: needs a real PEM file at the include path. + /// CliApp::new("internal-tool") + /// .spec(include_str!("schema.json")) + /// .endpoint("https://internal.example.com/graphql") + /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) + /// .run() + /// ``` + /// + /// Panics if the bytes don't parse as PEM, or if the PEM contains no + /// certificates. Failing fast at startup is preferable to silently + /// shipping a CLI that ignores its bundled cert. + pub fn extra_root_cert(mut self, pem: &[u8]) -> Self { + // Share the validation path with `HttpConfig::with_extra_root_cert` + // so error wording stays in sync between the panicking builder API + // and the Result-returning lower-level API. + let certs = crate::http::parse_extra_root_cert(pem) + .unwrap_or_else(|e| panic!("CliApp::extra_root_cert: {e}")); + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.push(pem.to_vec()); + self + } + + /// Decorate a clap `Command` with the auth help section and built-in debug flags. + /// Called from `GraphqlBinding::build_command()`. + pub(crate) fn decorate_command(&self, mut cli: clap::Command) -> clap::Command { + let existing_after_help = cli.get_after_help().map(|s| s.to_string()); + let auth_section = crate::auth::render_auth_help_section(&self.auth_bindings); + if existing_after_help.is_some() || auth_section.is_some() { + let mut sections: Vec<&str> = Vec::with_capacity(2); + if let Some(ref s) = existing_after_help { + sections.push(s); + } + if let Some(ref s) = auth_section { + sections.push(s); + } + cli = cli.after_help(sections.join("\n\n")); + } + cli = cli.arg( + clap::Arg::new("debug") + .long("debug") + .action(clap::ArgAction::SetTrue) + .global(true) + .help("Dump HTTP request and response to stderr"), + ); + cli + } + + + /// Construct the [`DynAuthProvider`] used for this run from the + /// registered bindings. GraphQL has no spec-declared schemes; with no + /// bindings, returns a `NoAuthProvider`. + pub(crate) fn build_auth_provider(&self) -> DynAuthProvider { + crate::auth::build_provider_with_strategy( + &self.auth_bindings, + &std::collections::HashMap::new(), + self.auth_strategy, + false, + ) + } + + /// Build an auth provider from externally-finalized bindings. + /// Used by `GraphqlBinding::dispatch` after CLI-bound auth sources + /// have been resolved against the parsed clap matches. + pub(crate) fn build_auth_provider_from_finalized( + &self, + finalized: &[(String, crate::auth::SchemeBinding)], + ) -> DynAuthProvider { + crate::auth::build_provider_with_strategy( + finalized, + &std::collections::HashMap::new(), + self.auth_strategy, + false, + ) + } +} + +/// One binding's worth of prepared state inside an [`AppContext`]. +pub(crate) struct BindingEntry { + pub(crate) doc: RestDescription, + pub(crate) auth_provider: DynAuthProvider, + pub(crate) http_config: crate::http::HttpConfig, +} + +/// Runtime context passed to custom command handlers. +/// +/// Provides access to the loaded API spec(s) and the constructed auth +/// provider(s). When multiple `GraphqlBinding`s are registered, +/// method lookups and execution are automatically routed to the +/// binding that owns the target method. +pub struct AppContext { + entries: Vec, + /// Whether `--quiet` was passed on the command line. + pub(crate) quiet: bool, + /// Whether `--debug` was passed on the command line. When true, the + /// executor dumps HTTP request/response traffic to stderr. + pub(crate) debug: bool, +} + +impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config }], + quiet: false, + debug: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + self + } + + pub(crate) fn with_debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + /// Add another binding's prepared state to this context. + pub(crate) fn add_entry(&mut self, entry: BindingEntry) { + self.entries.push(entry); + } + + /// Find which entry owns `method` by pointer identity. + fn entry_for_method(&self, method: &RestMethod) -> &BindingEntry { + for entry in &self.entries { + if resource_tree_contains_method(&entry.doc.resources, method) { + return entry; + } + } + &self.entries[0] + } + + /// Execute an API method by name, using the same executor as built-in + /// commands. Automatically routes to the binding that owns `method`. + pub fn execute( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + output_format: &formatter::OutputFormat, + ) -> Result<(), CliError> { + let entry = self.entry_for_method(method); + let pagination = executor::PaginationConfig::default(); + let pipeline = formatter::OutputPipeline { + format: output_format.clone(), + color_mode: formatter::ColorMode::default(), + quiet: self.quiet, + query: None, + }; + + // Programmatic execution from custom command handlers honors the + // default retry policy; there is no `--no-retry` opt-out on this path. + let retry_policy = executor::resolve_retry_policy(false); + + tokio::runtime::Handle::current() + .block_on(executor::execute_method( + &entry.doc, + method, + params_json, + body_json, + &entry.auth_provider, + false, + &pagination, + &pipeline, + false, + None, + &entry.http_config, + &retry_policy, + false, + false, // debug: programmatic callers never use the HTTP dump + )) + .map(|_| ()) + } + + /// Returns a reference to the loaded API spec. + /// + /// When multiple `GraphqlBinding`s are registered, this returns the + /// first binding's spec. Use [`find_method`](Self::find_method) to + /// search across all bindings. + pub fn spec(&self) -> &RestDescription { + &self.entries[0].doc + } + + /// Returns references to all loaded API specs. + pub fn specs(&self) -> Vec<&RestDescription> { + self.entries.iter().map(|e| &e.doc).collect() + } + + /// Search all registered specs for a method at `resource.method_name`. + pub fn find_method( + &self, + resource: &str, + method_name: &str, + ) -> Result<&RestMethod, CliError> { + for entry in &self.entries { + if let Some(r) = entry.doc.resources.get(resource) { + if let Some(m) = r.methods.get(method_name) { + return Ok(m); + } + } + } + Err(CliError::Validation(format!( + "no method '{method_name}' found in resource '{resource}' across {} binding(s)", + self.entries.len(), + ))) + } + + /// Returns a reference to the HTTP/TLS configuration for this CLI run. + /// + /// See [`crate::openapi::AppContext::http_config`] for the design + /// rationale and how non-reqwest transports consume this. + pub fn http_config(&self) -> &crate::http::HttpConfig { + &self.entries[0].http_config + } +} + +/// Recursively check whether any method in the resource tree is +/// pointer-equal to `target`. +fn resource_tree_contains_method( + resources: &std::collections::HashMap, + target: &RestMethod, +) -> bool { + for resource in resources.values() { + for m in resource.methods.values() { + if std::ptr::eq(m, target) { + return true; + } + } + if resource_tree_contains_method(&resource.resources, target) { + return true; + } + } + false +} + +/// Recursively walks clap ArgMatches to find the leaf method and its matches. +pub fn resolve_method_from_matches<'a>( + doc: &'a RestDescription, + matches: &'a clap::ArgMatches, +) -> Result<(&'a RestMethod, &'a clap::ArgMatches), CliError> { + let mut path: Vec<&str> = Vec::new(); + let mut current_matches = matches; + + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + path.push(sub_name); + current_matches = sub_matches; + } + + if path.is_empty() { + return Err(CliError::Validation( + "No resource or method specified".to_string(), + )); + } + + let resource_name = path[0]; + let resource = doc + .resources + .get(resource_name) + .ok_or_else(|| CliError::Validation(format!("Resource '{resource_name}' not found")))?; + + let mut current_resource = resource; + + for &name in &path[1..path.len() - 1] { + if let Some(sub) = current_resource.resources.get(name) { + current_resource = sub; + } else { + return Err(CliError::Validation(format!( + "Sub-resource '{name}' not found" + ))); + } + } + + let method_name = path[path.len() - 1]; + + if let Some(method) = current_resource.methods.get(method_name) { + return Ok((method, current_matches)); + } + + Err(CliError::Validation(format!( + "Method '{method_name}' not found on resource. Available methods: {:?}", + current_resource.methods.keys().collect::>() + ))) +} + +/// Collect individual flag values into a params map. +/// Values from --params JSON override individual flags. +pub(crate) fn collect_params_from_flags( + matched_args: &clap::ArgMatches, + method: &crate::graphql::discovery::GraphQLOperation, + params_override: Option<&str>, +) -> Result, CliError> { + let mut params = serde_json::Map::new(); + + // Collect values from individual flags + for param_name in method.parameters.keys() { + if let Some(value) = matched_args.get_one::(param_name) { + params.insert(param_name.clone(), serde_json::Value::String(value.clone())); + } + } + + // Override with --params JSON if provided (--params wins) + if let Some(json_str) = params_override { + let overrides: serde_json::Map = + serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))?; + for (key, value) in overrides { + params.insert(key, value); + } + } + + Ok(params) +} + +pub(crate) fn build_pagination_config( + matches: &clap::ArgMatches, + cli_name: &str, +) -> executor::PaginationConfig { + executor::PaginationConfig { + page_all: matches.get_flag("page-all"), + page_limit: matches + .get_one::("page-limit") + .copied() + .unwrap_or(10), + page_delay_ms: matches + .get_one::("page-delay") + .copied() + .unwrap_or(100), + no_pager: matches.get_flag("no-pager"), + cli_name: cli_name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_graphql_cli_app_builder() { + let app = CliApp::new("test").spec("{}"); + assert_eq!(app.name, "test"); + assert!(app.spec_json.is_some()); + } + + #[test] + fn test_graphql_auth_scheme_records_binding() { + let app = CliApp::new("t") + .spec("{}") + .auth_scheme("bearerAuth", AuthCredentialSource::from_env("T")); + assert_eq!(app.auth_bindings.len(), 1); + } + + #[test] + fn test_graphql_cli_app_endpoint() { + let app = CliApp::new("graphql-fixture") + .spec("{}") + .endpoint("https://example.com/graphql"); + assert_eq!(app.endpoint_url.as_deref(), Some("https://example.com/graphql")); + } + +} diff --git a/src/graphql/binding.rs b/src/graphql/binding.rs new file mode 100644 index 0000000..7284494 --- /dev/null +++ b/src/graphql/binding.rs @@ -0,0 +1,423 @@ +//! [`GraphqlBinding`] — adapts [`super::CliApp`] to the root +//! [`crate::binding::Binding`] trait so it can be composed into +//! a root-level [`crate::app::CliApp`]. + +use std::io::IsTerminal; +use std::sync::Arc; + +use crate::auth::{AuthCredentialSource, DynAuthProvider}; +use crate::binding::{Binding, BoxFuture, DispatchResult}; +use crate::error::CliError; +use crate::graphql::commands; +use crate::graphql::discovery::GraphQLSchema; +use crate::graphql::executor; + +struct Prepared { + doc: GraphQLSchema, + http_config: crate::http::HttpConfig, + auth_provider: DynAuthProvider, +} + +/// A GraphQL binding that wraps [`super::CliApp`]'s internals and +/// exposes them through the [`Binding`] trait. +#[must_use] +pub struct GraphqlBinding { + inner: super::CliApp, + /// When set, the entire GraphQL surface is mounted under this single + /// top-level command (e.g. `graphql`) instead of contributing its + /// resource groups directly at the root. Lets a GraphQL binding + /// coexist with another binding whose group names would otherwise + /// collide (e.g. an OpenAPI binding for the same vendor). + prefix: Option, + prepared: std::sync::Mutex>>, +} + +impl Default for GraphqlBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prefix: None, + prepared: std::sync::Mutex::new(None), + } + } +} + +impl GraphqlBinding { + /// Create a new GraphQL binding. The CLI name is set automatically + /// by `CliApp::binding()` — no need to pass it here. + pub fn new() -> Self { + Self::default() + } + + pub fn spec(mut self, json: &str) -> Self { + self.inner = self.inner.spec(json); + self + } + + pub fn endpoint(mut self, url: &str) -> Self { + self.inner = self.inner.endpoint(url); + self + } + + /// Mount the entire GraphQL surface under a single top-level command. + /// + /// Without this, the binding contributes its resource groups directly + /// at the CLI root (` payment …`). With `.under("graphql")`, the + /// same surface is reachable as ` graphql payment …`, freeing the + /// root namespace for another binding (e.g. a REST `OpenApiBinding`). + pub fn under(mut self, prefix: &str) -> Self { + self.prefix = Some(prefix.to_string()); + self + } + + pub fn auth_scheme_env(mut self, scheme_name: &str, env_var: &str) -> Self { + self.inner = self.inner.auth_scheme_env(scheme_name, env_var); + self + } + + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.inner = self.inner.auth_scheme(scheme_name, source); + self + } + + pub fn auth_provider( + mut self, + scheme_name: &str, + provider: impl crate::auth::provider::AuthProvider + 'static, + ) -> Self { + self.inner = self.inner.auth_provider(scheme_name, provider); + self + } + + fn ensure_prepared(&self) -> Result, CliError> { + let mut guard = self.prepared.lock().unwrap(); + if let Some(ref arc) = *guard { + return Ok(Arc::clone(arc)); + } + + let json = self.inner.spec_json.as_deref().ok_or_else(|| { + CliError::Discovery("No spec provided. Call .spec() on GraphqlBinding.".to_string()) + })?; + let endpoint = self.inner.endpoint_url.as_deref().ok_or_else(|| { + CliError::Discovery( + "No endpoint provided. Call .endpoint() on GraphqlBinding.".to_string(), + ) + })?; + let mut doc = crate::graphql::load_graphql_schema(json, &self.inner.name, endpoint)?; + + // If a prefix is configured, nest every top-level resource under a + // single synthetic resource. `build_cli`, `resolve_method_from_matches`, + // and the JSON-help walk all recurse through `resources`, so this is + // all that's needed to mount the whole surface under ``. + if let Some(prefix) = &self.prefix { + let original = std::mem::take(&mut doc.resources); + let wrapper = crate::graphql::discovery::GraphQLResource { + methods: std::collections::HashMap::new(), + resources: original, + }; + doc.resources = std::collections::HashMap::from([(prefix.clone(), wrapper)]); + } + + let http_config = crate::http::HttpConfig::new(&self.inner.name)? + .with_parsed_root_certs( + self.inner.extra_root_certs.iter().cloned(), + self.inner.extra_root_certs_pem.iter().cloned(), + ); + let auth_provider = self.inner.build_auth_provider(); + + let arc = Arc::new(Prepared { + doc, + http_config, + auth_provider, + }); + *guard = Some(Arc::clone(&arc)); + Ok(arc) + } + + /// Build a [`BindingEntry`](super::app::BindingEntry) from this + /// binding's prepared state and the current CLI matches. + fn build_binding_entry( + &self, + matches: &clap::ArgMatches, + ) -> Result { + let prepared = self.ensure_prepared()?; + + // Finalize CLI-arg-bound auth sources against parsed matches, + // mirroring dispatch() so custom command handlers get working auth. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized) + }; + + Ok(super::app::BindingEntry { + doc: prepared.doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + }) + } + + /// Wrap a typed handler function into a [`CliCommandHandler`] that + /// automatically downcasts the binding context to + /// [`AppContext`](super::AppContext). + /// + /// Use this with [`CliApp::command()`](crate::app::CliApp::command) + /// or [`CliApp::command_under()`](crate::app::CliApp::command_under). + pub fn handler( + f: fn(&clap::ArgMatches, &super::AppContext) -> Result<(), crate::error::CliError>, + ) -> crate::app::CliCommandHandler { + Box::new(move |matches: &clap::ArgMatches, ctx: &dyn std::any::Any| { + let ctx = ctx.downcast_ref::().ok_or_else(|| { + crate::error::CliError::Validation( + "handler requires a GraphQL binding context".into(), + ) + })?; + f(matches, ctx) + }) + } + + +} + +impl Binding for GraphqlBinding { + fn name(&self) -> &str { + &self.inner.name + } + + fn set_cli_name(&mut self, name: &str) { + self.inner.name = name.to_string(); + } + + fn set_root_auth(&mut self, bindings: &[(String, crate::auth::SchemeBinding)]) { + let mut merged = bindings.to_vec(); + merged.extend(std::mem::take(&mut self.inner.auth_bindings)); + self.inner.auth_bindings = merged; + } + + fn schema(&self, path: &[String]) -> Result, CliError> { + let prepared = self.ensure_prepared()?; + Ok(super::help::build_schema(&prepared.doc, path)) + } + + fn build_command(&self) -> Result { + let prepared = self.ensure_prepared()?; + let cli = commands::build_cli(&prepared.doc); + let mut cli = self.inner.decorate_command(cli); + + // Register global -- flags for CLI-bound auth sources + // so clap knows about them before parsing. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + for arg_name in &cli_auth_args { + let kebab = arg_name.replace('_', "-"); + cli = cli.arg( + clap::Arg::new(arg_name.clone()) + .long(kebab) + .global(true) + .value_name(arg_name.to_uppercase()) + .help("Auth credential"), + ); + } + + Ok(cli) + } + + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + let prepared = match self.ensure_prepared() { + Ok(p) => p, + Err(e) => return Box::pin(async move { Err(e) }), + }; + + Box::pin(async move { + // If any auth source uses CLI flags, finalize them against + // the parsed matches and rebuild the auth provider. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(root_matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized) + }; + + let (method, matched_args) = + super::resolve_method_from_matches(&prepared.doc, root_matches)?; + + let params_override = matched_args + .get_one::("params") + .map(|s| s.as_str()); + let params = super::app::collect_params_from_flags( + matched_args, + method, + params_override, + )?; + let params_json_string = serde_json::to_string(¶ms) + .map_err(|e| CliError::Validation(format!("Failed to serialize params: {e}")))?; + let params_json: Option<&str> = if params.is_empty() { + None + } else { + Some(¶ms_json_string) + }; + + let body_json_owned = crate::cli_args::resolve_body_json(matched_args)?; + let body_json = body_json_owned.as_deref(); + + // Both `--dry-run` and `--no-retry` are global debug flags; read + // them with `try_get_one` so an unmatched flag yields a clean + // `false` rather than a panic (defensive against future callers + // that do not register every built-in flag). + let dry_run = matched_args + .try_get_one::("dry-run") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let debug = root_matches + .try_get_one::("debug") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let pagination = super::app::build_pagination_config(matched_args, &self.inner.name); + let no_retry = matched_args + .try_get_one::("no-retry") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let retry_policy = executor::resolve_retry_policy(no_retry); + + let base_url_override_owned = + crate::cli_args::resolve_base_url_override(root_matches, &self.inner.name)?; + let base_url_override = base_url_override_owned.as_deref(); + + let http_config = prepared.http_config.clone().with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(root_matches), + ); + + // When --page-all is active on a TTY without --no-pager, + // let the executor write directly to the pager (capture_output + // = false). The executor spawns the pager and returns None, + // which maps to DispatchResult::Handled below. + let use_pager = pagination.page_all + && !pagination.no_pager + && std::io::stdout().is_terminal(); + let capture_output = !use_pager; + + let pipeline = crate::formatter::OutputPipeline::from_matches(root_matches, &self.inner.name) + .map_err(|e| CliError::Validation(e.to_string()))?; + if pipeline.is_http() { + return Err(CliError::Validation( + "the `http` output format is only supported for OpenAPI-based CLIs".to_string(), + )); + } + + let result = executor::execute_method( + &prepared.doc, + method, + params_json, + body_json, + &auth_provider, + dry_run, + &pagination, + &pipeline, + capture_output, + base_url_override, + &http_config, + &retry_policy, + no_retry, + debug, + ) + .await?; + + match result { + Some(value) => Ok(DispatchResult::Value(value)), + None => Ok(DispatchResult::Handled), + } + }) + } + + fn binding_context( + &self, + matches: &clap::ArgMatches, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let debug = matches.get_flag("debug"); + let http_config = entry.http_config.with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(matches), + ); + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + http_config, + ).with_quiet(quiet).with_debug(debug); + Ok(Some(Box::new(ctx))) + } + + fn merge_binding_context( + &self, + matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let debug = matches.get_flag("debug"); + let entry = super::app::BindingEntry { + http_config: entry.http_config.with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(matches), + ), + ..entry + }; + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + ctx.debug = debug; + ctx.quiet = quiet; + Ok(Some(ctx as Box)) + } + Err(original) => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet).with_debug(debug); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + ).with_quiet(quiet).with_debug(debug); + Ok(Some(Box::new(ctx))) + } + } + } +} diff --git a/src/graphql/commands.rs b/src/graphql/commands.rs new file mode 100644 index 0000000..aeae8c6 --- /dev/null +++ b/src/graphql/commands.rs @@ -0,0 +1,425 @@ +//! CLI Command Builder +//! +//! Builds a dynamic `clap::Command` tree from the internal API representation. + +use clap::builder::PossibleValuesParser; +use clap::{Arg, Command}; + +use crate::graphql::discovery::{GraphQLSchema as RestDescription, GraphQLResource as RestResource}; +use crate::text::to_kebab_flag; + +/// Names of built-in flags that must not be duplicated by parameter-derived flags. +const BUILTIN_FLAG_NAMES: &[&str] = &[ + "params", + "json", + "format", + "dry-run", + "base-url", + "page-all", + "page-limit", + "page-delay", + "no-pager", + "no-retry", + "quiet", + "query", + "help", + "debug", +]; + +/// Builds the full CLI command tree from an API description. +pub fn build_cli(doc: &RestDescription) -> Command { + let about_text = doc + .title + .clone() + .unwrap_or_else(|| format!("{} CLI", doc.name)); + let mut root = Command::new(doc.name.clone()) + .about(about_text) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json, table, yaml, csv, raw, jsonl. Default: table when stdout is a TTY, json when piped. Override default with _OUTPUT env var. raw emits unmodified server response bytes. jsonl emits one compact JSON value per line (NDJSON).") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ) + .arg( + clap::Arg::new("quiet") + .long("quiet") + .short('q') + .help("Suppress stdout output on success (errors still go to stderr)") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("no-retry") + .long("no-retry") + .help( + "Disable automatic retries on transient failures (5xx, 408, 429, \ + network errors). Useful for debugging.", + ) + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("query") + .long("query") + .help( + "JMESPath expression applied to the response before formatting. \ + For streaming responses, events whose projection is null are \ + suppressed (use as a per-event filter).", + ) + .value_name("EXPR") + .global(true), + ); + + // Add resource subcommands + let mut resource_names: Vec<_> = doc.resources.keys().collect(); + resource_names.sort(); + for name in resource_names { + let resource = &doc.resources[name]; + if let Some(cmd) = build_resource_command(name, resource) { + root = root.subcommand(cmd); + } + } + + root +} + +/// Recursively builds a Command for a resource. +/// Returns None if the resource has no methods or sub-resources. +fn build_resource_command(name: &str, resource: &RestResource) -> Option { + let mut cmd = Command::new(name.to_string()) + .about(format!("Operations on '{name}'")) + .subcommand_required(true) + .arg_required_else_help(true); + + let mut has_children = false; + + // Add method subcommands + let mut method_names: Vec<_> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + + has_children = true; + + let about = crate::text::truncate_description( + method.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + + let mut method_cmd = Command::new(method_name.to_string()) + .about(about) + .arg( + Arg::new("params") + .long("params") + .help("Additional parameters as JSON (overrides individual flags)") + .value_name("JSON"), + ) + .arg( + Arg::new("json") + .long("json") + .help("JSON string for the request body (use `-` to read from stdin)") + .value_name("JSON|-"), + ); + + // Pagination flags + method_cmd = method_cmd + .arg( + Arg::new("page-all") + .long("page-all") + .help("Auto-paginate through all results (NDJSON)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("page-limit") + .long("page-limit") + .help("Maximum number of pages to fetch (default: 10)") + .value_name("N") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("page-delay") + .long("page-delay") + .help("Delay in milliseconds between page fetches (default: 100)") + .value_name("MS") + .value_parser(clap::value_parser!(u64)), + ) + .arg( + Arg::new("no-pager") + .long("no-pager") + .help("Disable pager even on interactive terminals") + .action(clap::ArgAction::SetTrue), + ); + + // Generate individual flags from method parameters + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for param_name in param_names { + let kebab_name = to_kebab_flag(param_name); + if BUILTIN_FLAG_NAMES.contains(&kebab_name.as_str()) { + continue; + } + + let param = &method.parameters[param_name]; + + let value_name = match param.param_type.as_deref() { + Some("string") => "STRING", + Some("integer") => "NUMBER", + Some("number") => "NUMBER", + Some("boolean") => "BOOLEAN", + _ => "VALUE", + }; + + let help_text = crate::text::truncate_description( + param.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + + let mut arg = Arg::new(param_name.clone()) + .long(kebab_name) + .value_name(value_name) + .help(help_text); + + // Don't promote introspection defaults to clap defaults for flattened + // GraphQL input fields. Per the GraphQL spec, `defaultValue` on an input + // field describes the *server's* fallback when the client omits the field + // — it is not a value the client should always send. Materializing it as a + // clap default makes the flag look user-supplied, which forces the parent + // input object to materialize as a variable even when the user passed + // nothing for it, producing arguments the server may reject. + let is_graphql_input_field = param.graphql_input_arg.is_some(); + if let Some(ref default) = param.default { + if !is_graphql_input_field { + arg = arg.default_value(default.clone()); + } + } + + if let Some(ref enum_values) = param.enum_values { + arg = arg.value_parser(PossibleValuesParser::new(enum_values.clone())); + } + + method_cmd = method_cmd.arg(arg); + } + + cmd = cmd.subcommand(method_cmd); + } + + // Add sub-resource subcommands (recursive) + let mut sub_names: Vec<_> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub_resource = &resource.resources[sub_name]; + if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource) { + has_children = true; + cmd = cmd.subcommand(sub_cmd); + } + } + + if has_children { + Some(cmd) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graphql::discovery::{MethodParameter, GraphQLOperation as RestMethod, GraphQLResource as RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert("list".to_string(), RestMethod::default()); + methods.insert("delete".to_string(), RestMethod::default()); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_all_commands_always_shown() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let files_cmd = cmd + .find_subcommand("files") + .expect("files resource missing"); + + assert!(files_cmd.find_subcommand("list").is_some()); + assert!(files_cmd.find_subcommand("delete").is_some()); + } + + #[test] + fn test_root_uses_doc_name() { + let doc = make_doc(); + let cmd = build_cli(&doc); + assert_eq!(cmd.get_name(), "test-cli"); + } + + #[test] + fn test_method_params_become_flags() { + let mut params = HashMap::new(); + params.insert( + "uuid".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user UUID".to_string()), + required: true, + ..Default::default() + }, + ); + params.insert( + "status".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by status".to_string()), + enum_values: Some(vec!["active".to_string(), "inactive".to_string()]), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get-user".to_string(), + RestMethod { + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let users_cmd = cmd.find_subcommand("users").expect("users resource missing"); + let get_user_cmd = users_cmd + .find_subcommand("get-user") + .expect("get-user method missing"); + + // Verify individual flags exist + let args: Vec = get_user_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!(args.contains(&"uuid".to_string()), "uuid flag missing"); + assert!(args.contains(&"status".to_string()), "status flag missing"); + assert!(args.contains(&"params".to_string()), "params flag missing"); + } + + #[test] + fn test_builtin_flag_names_not_duplicated() { + let mut params = HashMap::new(); + params.insert( + "format".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Response format".to_string()), + ..Default::default() + }, + ); + params.insert( + "real_param".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("A real param".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "test-method".to_string(), + RestMethod { + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // This should not panic from duplicate arg names + let cmd = build_cli(&doc); + let things_cmd = cmd + .find_subcommand("things") + .expect("things resource missing"); + let test_cmd = things_cmd + .find_subcommand("test-method") + .expect("test-method missing"); + + let args: Vec = test_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "format" should NOT appear as a duplicated param flag, + // but "real_param" should be present. + assert!( + args.contains(&"real_param".to_string()), + "real_param flag missing" + ); + + // Count occurrences of "format" — should be at most 1 (from the global flag) + let format_count = args.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "format flag duplicated: found {format_count}" + ); + } +} diff --git a/src/graphql/discovery.rs b/src/graphql/discovery.rs new file mode 100644 index 0000000..0f7c72a --- /dev/null +++ b/src/graphql/discovery.rs @@ -0,0 +1,145 @@ +//! Internal GraphQL Representation +//! +//! Data structures the parser produces from a GraphQL introspection JSON +//! and the command builder + executor consume. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// Top-level GraphQL schema description. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GraphQLSchema { + pub name: String, + pub version: String, + pub title: Option, + pub description: Option, + /// Endpoint URL the executor POSTs queries to. + pub root_url: String, + #[serde(default)] + pub resources: HashMap, +} + +/// A resource which can contain operations and nested sub-resources. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLResource { + #[serde(default)] + pub methods: HashMap, + #[serde(default)] + pub resources: HashMap, +} + +/// A single GraphQL operation (query or mutation). +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GraphQLOperation { + pub id: Option, + pub description: Option, + #[serde(default)] + pub parameters: HashMap, + /// GraphQL operation metadata: query/mutation kind, field name, args, return shape. + pub graphql: Option, + /// Per-method base URL (populated from the spec's server URL during parsing). + /// When non-empty, takes priority over doc.root_url in URL construction. + #[serde(default)] + pub root_url: String, +} + +/// Metadata for a GraphQL operation. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLMethodInfo { + /// "query" or "mutation". + pub operation_type: String, + /// The original field name in the schema (e.g., "issueCreate"). + pub field_name: String, + /// Default selection set as a GraphQL fragment string (e.g., "{ id title createdAt }"). + pub default_selection: String, + /// Ordered list of top-level arguments, used to build `$var: Type` declarations. + #[serde(default)] + pub args: Vec, +} + +/// One argument of a GraphQL operation. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct GraphQLArgDef { + /// camelCase argument name as it appears in the schema (e.g., "id", "input"). + pub name: String, + /// kebab-case CLI flag key used to look this argument up in the params map. + pub flag_key: String, + /// Full GraphQL type string including nullability (e.g., "String!", "IssueCreateInput"). + pub gql_type: String, + /// True when this arg takes an input object whose fields were flattened into CLI flags. + pub is_input: bool, + /// True when the argument's GraphQL type is a list (e.g., `[IssueSortInput!]`). + /// Used at variable-build time to wrap the reconstructed input object in a JSON array. + #[serde(default)] + pub is_list: bool, +} + +/// A CLI parameter derived from a GraphQL argument or flattened input field. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MethodParameter { + /// JSON-Schema-flavored type used for value coercion (string/integer/number/boolean). + #[serde(rename = "type")] + pub param_type: Option, + pub description: Option, + #[serde(default)] + pub required: bool, + pub default: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + /// For flattened input fields: the camelCase name of the top-level argument. + /// E.g., a field flattened from `input: IssueCreateInput` has + /// `graphql_input_arg = Some("input")`. + #[serde(default)] + pub graphql_input_arg: Option, + /// Dotted camelCase path within the input argument for nested input fields. + /// E.g., a field at `input.dateRange.start` has + /// `graphql_field_path = Some("dateRange.start")`. When absent, the path is + /// derived from the flag key (top-level flattened field). + #[serde(default)] + pub graphql_field_path: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_graphql_schema() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/graphql", + "resources": { + "issue": { + "methods": { + "get": {} + } + } + } + }"#; + + let doc: GraphQLSchema = serde_json::from_str(json).unwrap(); + assert_eq!(doc.name, "test"); + assert_eq!(doc.root_url, "https://api.example.com/graphql"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get")); + } + + #[test] + fn test_deserialize_defaults() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/graphql" + }"#; + + let doc: GraphQLSchema = serde_json::from_str(json).unwrap(); + assert!(doc.resources.is_empty()); + } + +} diff --git a/src/graphql/executor.rs b/src/graphql/executor.rs new file mode 100644 index 0000000..14cdf3a --- /dev/null +++ b/src/graphql/executor.rs @@ -0,0 +1,1477 @@ +//! GraphQL Request Execution +//! +//! Builds and dispatches POST requests carrying GraphQL operations. +//! Handles auth, response unwrapping (`data` envelope and `errors`), +//! and cursor-based pagination via `pageInfo.endCursor`. + +use std::collections::HashMap; + +use anyhow::Context; +use serde_json::{json, Map, Value}; + +use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::graphql::discovery::{ + GraphQLArgDef, GraphQLMethodInfo, GraphQLOperation, GraphQLSchema, MethodParameter, +}; + +/// Configuration for cursor-based auto-pagination. +#[derive(Debug, Clone)] +pub struct PaginationConfig { + /// Whether to auto-paginate through all pages. + pub page_all: bool, + /// Maximum number of pages to fetch (default: 10). + pub page_limit: u32, + /// Delay between page fetches in milliseconds (default: 100). + pub page_delay_ms: u64, + /// Disable the pager even on interactive terminals (`--no-pager`). + pub no_pager: bool, + /// CLI binary name, used for the `_PAGER` env var lookup. + pub cli_name: String, +} + +impl Default for PaginationConfig { + fn default() -> Self { + Self { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + no_pager: false, + cli_name: String::new(), + } + } +} + +/// Parsed inputs ready for request execution. +#[derive(Debug)] +struct ExecutionInput { + params: Map, + body: Value, + full_url: String, +} + +fn parse_and_validate_inputs( + doc: &GraphQLSchema, + method: &GraphQLOperation, + params_json: Option<&str>, + body_json: Option<&str>, + base_url_override: Option<&str>, +) -> Result { + let params: Map = if let Some(p) = params_json { + serde_json::from_str(p) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))? + } else { + Map::new() + }; + + let gql = method.graphql.as_ref().ok_or_else(|| { + CliError::Discovery("GraphQL method info missing from spec".to_string()) + })?; + + for (param_name, param_def) in &method.parameters { + if param_def.required + && !params.contains_key(param_name) + && param_def.graphql_input_arg.is_none() + { + return Err(CliError::Validation(format!( + "Required parameter '{param_name}' is missing" + ))); + } + } + + let body = build_graphql_body(gql, ¶ms, body_json, &method.parameters, None)?; + let full_url = base_url_override + .map(|u| u.trim_end_matches('/').to_string()) + .unwrap_or_else(|| doc.root_url.clone()); + + Ok(ExecutionInput { params, body, full_url }) +} + +/// Build a POST request with auth and a JSON GraphQL body. +fn build_http_request( + client: &reqwest::Client, + input: &ExecutionInput, + auth_provider: &DynAuthProvider, +) -> Result { + let request = client.post(&input.full_url); + // GraphQL has no per-operation security metadata in the introspection + // schema, so the metadata is always "unspecified" — the provider's own + // default policy decides what to attach. + let request = auth_provider.apply(request, &EndpointAuthMetadata::unspecified())?; + let request = request + .header("Content-Type", "application/json") + .json(&input.body); + Ok(request) +} + +/// Parse a GraphQL response body: surface `errors` and unwrap the `data` envelope. +/// +/// GraphQL allows partial results: a response may have both `data` and `errors` +/// (common in federation). When both are present, errors are printed to stderr +/// and the partial data is returned. Only when there is no `data` at all do we +/// treat the errors as fatal. +fn parse_graphql_response(body_text: &str) -> Result { + let json_val: Value = serde_json::from_str(body_text).map_err(|e| CliError::Api { + code: 400, + message: format!("Invalid GraphQL response: {e}"), + reason: "graphql_parse_error".to_string(), + })?; + + let has_data = json_val + .get("data") + .map(|d| !d.is_null()) + .unwrap_or(false); + + if let Some(errors) = json_val.get("errors").and_then(|e| e.as_array()) { + if !errors.is_empty() { + let message = errors + .iter() + .filter_map(|e| e.get("message").and_then(|m| m.as_str())) + .collect::>() + .join("; "); + if has_data { + eprintln!("GraphQL partial errors: {message}"); + } else { + return Err(CliError::Api { + code: 400, + message, + reason: "graphql_error".to_string(), + }); + } + } + } + + let unwrapped = if let Some(data) = json_val.get("data").filter(|d| !d.is_null()) { + if let Value::Object(map) = data { + if map.len() == 1 { + map.values().next().unwrap().clone() + } else { + data.clone() + } + } else { + data.clone() + } + } else { + json_val + }; + + serde_json::to_string(&unwrapped).map_err(|e| CliError::Api { + code: 500, + message: format!("Failed to serialize GraphQL response: {e}"), + reason: "graphql_serialize_error".to_string(), + }) +} + +/// Print or capture a JSON response and bump the page counter. +async fn handle_json_response( + body_text: &str, + pipeline: &crate::formatter::OutputPipeline, + pages_fetched: &mut u32, + page_all: bool, + capture_output: bool, + captured: &mut Vec, + pager: &mut Option, +) -> Result<(), CliError> { + if let Ok(json_val) = serde_json::from_str::(body_text) { + *pages_fetched += 1; + + if capture_output { + captured.push(json_val); + } else if page_all { + let is_first_page = *pages_fetched == 1; + if let Some(ref mut pager_handle) = pager { + pipeline + .emit(pager_handle, &json_val, true, is_first_page) + .context("Failed to write output")?; + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &json_val, true, is_first_page) + .context("Failed to write output")?; + } + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &json_val, false, true) + .context("Failed to write output")?; + } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); + } + Ok(()) +} + + +/// Resolve the retry policy for a run from the `--no-retry` opt-out. +/// +/// `--no-retry` is a user-facing debug switch that disables retries entirely; +/// otherwise the SDK's [default policy](crate::http::RetryPolicy::default) is +/// applied. Threading a `RetryPolicy` (rather than hardcoding the default in +/// the executor) keeps the GraphQL path at parity with the OpenAPI executor, +/// where the policy is configurable per run. +pub fn resolve_retry_policy(no_retry: bool) -> crate::http::RetryPolicy { + if no_retry { + crate::http::RetryPolicy::disabled() + } else { + crate::http::RetryPolicy::default() + } +} + +/// Executes a GraphQL operation. +/// +/// Posts the rendered query to the schema's endpoint, unwraps the `data` envelope, +/// and continues paginating via `pageInfo.endCursor` until the page limit is hit. +/// +/// `retry_policy` is threaded in by the caller (see [`resolve_retry_policy`]) +/// so the policy is configurable per run rather than hardcoded here. `no_retry` +/// is still honored as a hard short-circuit inside [`crate::http::decide_retry`]. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method( + doc: &GraphQLSchema, + method: &GraphQLOperation, + params_json: Option<&str>, + body_json: Option<&str>, + auth_provider: &DynAuthProvider, + dry_run: bool, + pagination: &PaginationConfig, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, + base_url_override: Option<&str>, + http_config: &crate::http::HttpConfig, + retry_policy: &crate::http::RetryPolicy, + no_retry: bool, + debug: bool, +) -> Result, CliError> { + let mut input = + parse_and_validate_inputs(doc, method, params_json, body_json, base_url_override)?; + + if dry_run { + let dry_run_info = json!({ + "dry_run": true, + "url": input.full_url, + "method": "POST", + "body": input.body, + }); + if capture_output { + return Ok(Some(dry_run_info)); + } + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &dry_run_info, false, true) + .context("Failed to write output")?; + return Ok(None); + } + + let mut pages_fetched: u32 = 0; + let mut captured_values = Vec::new(); + + // Spawn an external pager when --page-all is active on a TTY. + let pager_label = method.id.as_deref().unwrap_or("graphql"); + let mut pager_handle = if pagination.page_all && !pagination.no_pager && !capture_output { + let pager_config = crate::pager::PagerConfig::from_env(&pagination.cli_name); + crate::pager::spawn_pager(&pager_config, pager_label) + } else { + None + }; + + // GraphQL auth is always Authorization: Bearer — covered by the static + // denylist in debug.rs. GraphQL introspection schemas carry no security + // scheme metadata, so there are no spec-declared api-key-in-header names + // to add. + let extra_sensitive_headers: &[&str] = &[]; + + let client = http_config.build_client()?; + + loop { + let method_id = method.id.as_deref().unwrap_or("unknown"); + let start = std::time::Instant::now(); + + // Fresh key per page so the server doesn't deduplicate distinct + // pages, but stable across retries of the same page. + let idempotency_key = Some(crate::http::generate_idempotency_key()); + + // Retry loop — same pattern as the OpenAPI executor. + let mut retry_attempt: u32 = 0; + let response = loop { + let mut request = build_http_request(&client, &input, auth_provider)?; + if let Some(ref key) = idempotency_key { + request = request.header("Idempotency-Key", key.as_str()); + } + + let built = request.build().map_err(|e| { + CliError::Other(anyhow::Error::from(e).context("Failed to build HTTP request")) + })?; + if debug { + let query_str = input.body.get("query").and_then(|q| q.as_str()).unwrap_or(""); + let empty_vars = Value::Object(Map::new()); + let variables = input.body.get("variables").unwrap_or(&empty_vars); + crate::debug::dump_graphql_request( + built.url().as_str(), + built.headers(), + query_str, + variables, + extra_sensitive_headers, + ); + } + match client.execute(built).await { + Ok(resp) => { + let status = resp.status(); + let retry_after_header = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let outcome = crate::http::RetryOutcome { + status: Some(status.as_u16()), + retry_after: retry_after_header.as_deref(), + }; + if let Some(delay) = crate::http::decide_retry( + retry_attempt, + &outcome, + retry_policy, + "POST", + idempotency_key.is_some(), + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + "retrying after retryable HTTP status", + ); + let _ = resp.bytes().await; + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + break resp; + } + Err(e) => { + // See the OpenAPI executor: a refused redirect must not be + // retried, nor reported as an internal error. + if let Some(err) = crate::http::redirect_refusal_error(&e) { + return Err(err); + } + let outcome = crate::http::RetryOutcome { + status: None, + retry_after: None, + }; + if let Some(delay) = crate::http::decide_retry( + retry_attempt, + &outcome, + retry_policy, + "POST", + idempotency_key.is_some(), + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = "POST", + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + error = %e, + "retrying after network/transport failure", + ); + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } + } + }; + let latency_ms = start.elapsed().as_millis() as u64; + + let status = response.status(); + let response_headers = response.headers().clone(); + + if !status.is_success() { + let error_body = response.text().await.unwrap_or_default(); + tracing::warn!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + latency_ms = latency_ms, + "API error" + ); + if debug { + crate::debug::dump_error_response( + status.as_u16(), + latency_ms, + &response_headers, + &error_body, + extra_sensitive_headers, + ); + } + return handle_error_response( + status, + &error_body, + auth_provider.as_ref(), + &EndpointAuthMetadata::unspecified(), + ); + } + + tracing::debug!( + api_method = method_id, + http_method = "POST", + status = status.as_u16(), + latency_ms = latency_ms, + page = pages_fetched, + "API request" + ); + + let body_text = response + .text() + .await + .context("Failed to read response body")?; + if debug { + crate::debug::dump_response( + status.as_u16(), + latency_ms, + &response_headers, + &body_text, + extra_sensitive_headers, + ); + } + let response_body = parse_graphql_response(&body_text)?; + + handle_json_response( + &response_body, + pipeline, + &mut pages_fetched, + pagination.page_all, + capture_output, + &mut captured_values, + &mut pager_handle, + ) + .await?; + + // GraphQL cursor-based pagination: rebuild the body with the next + // cursor and POST again until we run out of pages or hit the limit. + if pagination.page_all { + if let Some(cursor) = extract_graphql_cursor(&response_body) { + if pages_fetched < pagination.page_limit { + if let Some(ref gql_info) = method.graphql { + let params_clone = input.params.clone(); + input.body = build_graphql_body( + gql_info, + ¶ms_clone, + body_json, + &method.parameters, + Some(&cursor), + )?; + } + if pagination.page_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + pagination.page_delay_ms, + )) + .await; + } + continue; + } + } + } + + break; + } + + // Close the pager pipe and wait for it to exit before returning. + drop(pager_handle); + + if capture_output && !captured_values.is_empty() { + if captured_values.len() == 1 { + return Ok(Some(captured_values.pop().unwrap())); + } else { + return Ok(Some(Value::Array(captured_values))); + } + } + + Ok(None) +} + +/// Build a GraphQL request body using the variables mechanism. +/// +/// User-supplied values are placed in the `variables` JSON object and referenced +/// via `$name: Type` declarations in the query — they never appear in the query +/// string itself, preventing GraphQL injection. +/// +/// `cursor` injects an `after` variable for cursor-based pagination when +/// `page-all` is in effect; it is only applied when the method declares an +/// `after` argument. +fn build_graphql_body( + gql: &GraphQLMethodInfo, + params: &Map, + body_json: Option<&str>, + method_params: &HashMap, + cursor: Option<&str>, +) -> Result { + let mut variables: Map = Map::new(); + + // JFL-1.2: enforce mutually exclusive input modes — `--json`, dot-notation + // leaf flags, and the object-shorthand flag for an input arg cannot be + // combined. Track raw provided keys (pre-`set_nested_value`) so we can + // detect both kinds of collision before assembling the body. + if body_json.is_some() { + if let Some(conflicting_flag) = params + .keys() + .find(|k| flag_targets_input(k, method_params, &gql.args)) + { + return Err(CliError::Validation(format!( + "Cannot combine --json with per-field input flags (--{conflicting_flag}). Use one or the other." + ))); + } + } + for object_key in params.keys() { + let mp = match method_params.get(object_key) { + Some(mp) => mp, + None => continue, + }; + if mp.param_type.as_deref() != Some("object") { + continue; + } + // Nested-field object shorthand (e.g. `--date-range` + `--date-range.start`): + // detect by dotted prefix collision. + let prefix = format!("{object_key}."); + if let Some(leaf_key) = params.keys().find(|k| k.starts_with(&prefix)) { + return Err(CliError::Validation(format!( + "Cannot combine --{object_key} with --{leaf_key}. Use the JSON shorthand or individual flags, not both." + ))); + } + // JFL-1.4: input-arg-level shorthand (e.g. `--filter '{...}'`) has an + // empty `graphql_field_path`. Its per-field flags do NOT share the + // arg-name prefix (they live at the top of the flag namespace), so + // the prefix check above misses them. Catch the conflict by matching + // on the same `graphql_input_arg`. + if mp.graphql_field_path.as_deref().unwrap_or("").is_empty() { + if let Some(input_arg) = mp.graphql_input_arg.as_deref() { + if let Some(conflicting) = params.keys().find(|k| { + k.as_str() != object_key + && method_params + .get(*k) + .and_then(|m| m.graphql_input_arg.as_deref()) + == Some(input_arg) + }) { + return Err(CliError::Validation(format!( + "Cannot combine --{object_key} with --{conflicting}. Use the JSON shorthand or individual flags, not both." + ))); + } + } + } + } + + // Parse --json once; it targets the first input arg only. + let body_obj: Option> = if let Some(json_str) = body_json { + let json_val: Value = serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --json body: {e}")))?; + match json_val { + Value::Object(obj) => Some(obj), + _ => None, + } + } else { + None + }; + let mut json_applied = false; + + for arg_def in &gql.args { + if arg_def.is_input { + // Reconstruct the input object from flattened CLI flags. Each flag + // tagged with this arg_name carries a graphql_field_path (dotted + // camelCase path within the input) for nested field placement. + let mut input_obj: Map = Map::new(); + for (flag_key, value) in params { + if let Some(mp) = method_params.get(flag_key) { + if mp.graphql_input_arg.as_deref() == Some(arg_def.name.as_str()) { + let coerced = coerce_graphql_value(value, Some(mp))?; + // Object-shorthand flag for the *whole* input arg + // (graphql_field_path is empty): coerce_graphql_value + // guarantees `coerced` is an object when param_type + // is "object", so we can merge its fields into + // input_obj at the top level. Non-object payloads are + // rejected upstream as a validation error. + let path = mp.graphql_field_path.as_deref().unwrap_or(""); + if path.is_empty() { + if let Value::Object(map) = coerced { + for (k, v) in map { + input_obj.insert(k, v); + } + } + } else { + set_nested_value(&mut input_obj, path, coerced); + } + } + } + } + // --json targets the first input arg only (deep-merges at the top level). + if !json_applied { + if let Some(ref obj) = body_obj { + for (k, v) in obj { + input_obj.insert(k.clone(), v.clone()); + } + json_applied = true; + } + } + if !input_obj.is_empty() { + // For list-typed input arguments (e.g. `arg: [SomeInput!]`), the + // variable must be serialized as a JSON array. The GraphQL spec + // defines input coercion that lifts a single value into a singleton + // list, but coercion of typed *variables* is not uniformly enforced + // across server implementations — emitting an explicit array is the + // portable, spec-conformant shape. We currently flatten one element's + // worth of fields, so wrap the reconstructed object accordingly. + let value = if arg_def.is_list { + Value::Array(vec![Value::Object(input_obj)]) + } else { + Value::Object(input_obj) + }; + variables.insert(arg_def.name.clone(), value); + } + } else { + // Direct scalar/enum arg: look it up by its CLI flag key. + if let Some(value) = params.get(&arg_def.flag_key) { + let coerced = coerce_graphql_value(value, method_params.get(&arg_def.flag_key))?; + variables.insert(arg_def.name.clone(), coerced); + } + } + } + + // Inject pagination cursor when the method declares an `after` argument. + if let Some(cursor_val) = cursor { + if gql.args.iter().any(|a| a.name == "after") { + variables.insert("after".to_string(), Value::String(cursor_val.to_string())); + } + } + + let op_type = &gql.operation_type; + let field_name = &gql.field_name; + let selection = &gql.default_selection; + + let query = if variables.is_empty() { + format!("{op_type} {{ {field_name} {selection} }}") + } else { + let present_args: Vec<&GraphQLArgDef> = gql + .args + .iter() + .filter(|a| variables.contains_key(&a.name)) + .collect(); + let decls = present_args + .iter() + .map(|a| format!("${}: {}", a.name, a.gql_type)) + .collect::>() + .join(", "); + let refs = present_args + .iter() + .map(|a| format!("{}: ${}", a.name, a.name)) + .collect::>() + .join(", "); + format!("{op_type}({decls}) {{ {field_name}({refs}) {selection} }}") + }; + + Ok(json!({ + "query": query, + "variables": variables, + })) +} + +/// True when the given CLI flag key corresponds to an input arg (either a +/// flattened input field or a top-level input arg passed as object shorthand). +/// Direct scalar/enum args are not "body" inputs — they map to dedicated GraphQL +/// arguments and aren't subject to the `--json` exclusivity rule. +fn flag_targets_input( + flag_key: &str, + method_params: &HashMap, + args: &[GraphQLArgDef], +) -> bool { + if let Some(mp) = method_params.get(flag_key) { + if mp.graphql_input_arg.is_some() { + return true; + } + } + // Also catch object-shorthand for the input arg itself when the parser + // didn't tag it (defensive — current parser always tags it). + args.iter().any(|a| a.is_input && a.flag_key == flag_key) +} + +/// Set a value at a dotted camelCase path within a JSON object, creating +/// intermediate objects as needed. E.g., path `"dateRange.start"` sets +/// `obj["dateRange"]["start"] = value`. +fn set_nested_value(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.insert(path.to_string(), value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(nested_map) = nested { + set_nested_value(nested_map, tail, value); + } + } + } +} + +/// Extract `endCursor` from an unwrapped GraphQL response when `hasNextPage` is true. +fn extract_graphql_cursor(response_body: &str) -> Option { + let val: Value = serde_json::from_str(response_body).ok()?; + let page_info = val.get("pageInfo")?; + let has_next = page_info.get("hasNextPage")?.as_bool()?; + if !has_next { + return None; + } + page_info + .get("endCursor")? + .as_str() + .map(|s| s.to_string()) +} + +/// Coerce a JSON value to the correct type based on the parameter definition. +/// CLI flags always come in as strings; this converts "3" → 3 for integers, etc. +/// +/// JFL-1.2: `param_type=="object"` (the input-shorthand flag) parses the +/// string as JSON. An invalid payload is a hard validation error rather than +/// a silent fallback to the raw string — a misuse should be loud, not become +/// a confusing GraphQL "expected object, got string" later in the pipeline. +fn coerce_graphql_value( + value: &Value, + param_def: Option<&MethodParameter>, +) -> Result { + // Object-shorthand inputs may arrive as a raw String (CLI flag path) or + // pre-decoded (the `--params` JSON path inserts arbitrary Value shapes + // directly into the params map). Validate shape uniformly across both + // paths — otherwise a `--params '{"input": [1,2,3]}'` would slip past + // here and then silently drop in build_graphql_body's Value::Object + // guard, with the user's input vanishing without an error. + if param_def.and_then(|d| d.param_type.as_deref()) == Some("object") { + let parsed = if let Value::String(s) = value { + serde_json::from_str::(s).unwrap_or_else(|_| value.clone()) + } else { + value.clone() + }; + if !parsed.is_object() { + return Err(CliError::Validation(format!( + "Object-shorthand flag must be a JSON object, got {}", + match &parsed { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => unreachable!(), + } + ))); + } + return Ok(parsed); + } + + if let Value::String(s) = value { + if let Some(def) = param_def { + match def.param_type.as_deref() { + Some("integer") => { + if let Ok(n) = s.parse::() { + return Ok(Value::Number(n.into())); + } + } + Some("number") => { + if let Ok(n) = s.parse::() { + if let Some(num) = serde_json::Number::from_f64(n) { + return Ok(Value::Number(num)); + } + } + } + Some("boolean") => match s.as_str() { + "true" => return Ok(Value::Bool(true)), + "false" => return Ok(Value::Bool(false)), + _ => {} + }, + _ => {} + } + } + } + Ok(value.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_pagination_config_default() { + let config = PaginationConfig::default(); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + } + + // ----------------------------------------------------------------------- + // handle_json_response + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_handle_json_response_capture_output() { + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut captured = Vec::new(); + + let mut pager_none: Option = None; + handle_json_response( + r#"{"items":["a"]}"#, + &pipeline, + &mut pages_fetched, + false, + true, + &mut captured, + &mut pager_none, + ) + .await + .unwrap(); + + assert_eq!(captured.len(), 1); + assert_eq!(pages_fetched, 1); + } + + #[tokio::test] + async fn test_handle_json_response_non_json_body() { + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut captured = Vec::new(); + + let mut pager_none: Option = None; + handle_json_response( + "not json at all", + &pipeline, + &mut pages_fetched, + false, + false, + &mut captured, + &mut pager_none, + ) + .await + .unwrap(); + + assert_eq!(pages_fetched, 0); + } + + // ----------------------------------------------------------------------- + // build_http_request + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_build_http_request_posts_json_body() { + let client = reqwest::Client::new(); + let input = ExecutionInput { + full_url: "https://example.com/graphql".to_string(), + body: json!({"query": "{ ping }", "variables": {}}), + params: Map::new(), + }; + + let request = build_http_request(&client, &input, &crate::auth::no_auth_provider()).unwrap(); + let built = request.build().unwrap(); + + assert_eq!(built.method(), "POST"); + assert_eq!( + built + .headers() + .get("Content-Type") + .and_then(|v| v.to_str().ok()), + Some("application/json"), + ); + } + + // ----------------------------------------------------------------------- + // execute_method + // ----------------------------------------------------------------------- + + fn minimal_ping_doc_and_method() -> (GraphQLSchema, GraphQLOperation) { + let doc = GraphQLSchema { + name: "test".to_string(), + version: "v1".to_string(), + root_url: "https://example.com/graphql".to_string(), + ..Default::default() + }; + let method = GraphQLOperation { + id: Some("ping".to_string()), + graphql: Some(crate::graphql::discovery::GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "ping".to_string(), + default_selection: String::new(), + args: Vec::new(), + }), + ..Default::default() + }; + (doc, method) + } + + #[tokio::test] + async fn test_execute_method_dry_run_with_http_config() { + // dry_run skips network I/O entirely, but still exercises the new + // http_config parameter path — proving that the caller's + // HttpConfig is plumbed all the way to execute_method. + let (doc, method) = minimal_ping_doc_and_method(); + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let retry_policy = crate::http::RetryPolicy::default(); + + let result = execute_method( + &doc, + &method, + None, + None, + &crate::auth::no_auth_provider(), + true, // dry_run + &pagination, + &pipeline, + true, // capture_output + None, + &http_config, + &retry_policy, + false, + false, // debug + ) + .await + .expect("dry-run should succeed"); + + let value = result.expect("dry-run with capture_output should return Some"); + assert_eq!(value["dry_run"], json!(true)); + assert_eq!(value["url"], json!("https://example.com/graphql")); + assert_eq!(value["method"], json!("POST")); + } + + // ----------------------------------------------------------------------- + // resolve_retry_policy + retry threading + // ----------------------------------------------------------------------- + + #[test] + fn test_resolve_retry_policy_default_vs_disabled() { + // Without --no-retry the SDK default policy is applied. + let enabled = resolve_retry_policy(false); + assert_eq!(enabled, crate::http::RetryPolicy::default()); + assert!(enabled.enabled); + // --no-retry maps to the fully-disabled policy. + let disabled = resolve_retry_policy(true); + assert_eq!(disabled, crate::http::RetryPolicy::disabled()); + assert!(!disabled.enabled); + } + + /// Run `execute_method` against a mock server, returning the result. + /// Auth is `no_auth` and the operation is the minimal `ping` query, so + /// every request is a bare `POST /graphql`. + async fn run_against_mock( + base_url: &str, + retry_policy: &crate::http::RetryPolicy, + no_retry: bool, + ) -> Result, CliError> { + let (doc, method) = minimal_ping_doc_and_method(); + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let http_config = crate::http::HttpConfig::new("test").unwrap(); + execute_method( + &doc, + &method, + None, + None, + &crate::auth::no_auth_provider(), + false, // dry_run + &pagination, + &pipeline, + true, // capture_output + Some(base_url), + &http_config, + retry_policy, + no_retry, + false, // debug + ) + .await + } + + #[tokio::test] + async fn test_no_retry_yields_single_attempt() { + use wiremock::matchers::method as http_method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + // 503 is retryable, but --no-retry must short-circuit to a single + // attempt. `expect(1)` fails the test if the executor sends a retry. + Mock::given(http_method("POST")) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + + let base_url = format!("{}/graphql", server.uri().trim_end_matches('/')); + // With --no-retry the policy is disabled and `no_retry` is true; both + // signals agree on "do not retry". + let policy = resolve_retry_policy(true); + let result = run_against_mock(&base_url, &policy, true).await; + + assert!(result.is_err(), "503 with --no-retry should surface as an error"); + // `expect(1)` is verified on drop — exactly one request was sent. + } + + #[tokio::test] + async fn test_retryable_status_retries_under_default_policy() { + use wiremock::matchers::method as http_method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + // First attempt: 503 (retryable). Second attempt: 200 success. + Mock::given(http_method("POST")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(http_method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "data": { "ping": "pong" } + }))) + .expect(1) + .mount(&server) + .await; + + // A short base delay keeps the test fast while still exercising the + // real backoff/sleep path of the default-shaped policy. + let policy = crate::http::RetryPolicy { + base_delay_ms: 1, + ..crate::http::RetryPolicy::default() + }; + let result = run_against_mock(&base_url_of(&server), &policy, false).await; + + let value = result.expect("should succeed after one retry"); + let value = value.expect("capture_output should return Some"); + assert_eq!(value, json!("pong"), "single-field data envelope is unwrapped"); + // Both `expect(1)` mocks are verified on drop: exactly two requests + // total — one 503, one 200 — proving the retryable status retried. + } + + fn base_url_of(server: &wiremock::MockServer) -> String { + format!("{}/graphql", server.uri().trim_end_matches('/')) + } + + // ----------------------------------------------------------------------- + // parse_graphql_response + // ----------------------------------------------------------------------- + + #[test] + fn test_parse_graphql_response_errors_only_is_fatal() { + let body = json!({ + "errors": [{"message": "Not found"}] + }) + .to_string(); + let result = parse_graphql_response(&body); + assert!(result.is_err(), "errors-only should be fatal"); + } + + #[test] + fn test_parse_graphql_response_errors_and_data_returns_data() { + let body = json!({ + "data": {"node": {"id": "n1", "name": "test"}}, + "errors": [{"message": "partial failure"}] + }) + .to_string(); + let result = parse_graphql_response(&body).expect("errors+data should succeed"); + let val: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(val["id"], "n1", "partial data should be returned"); + } + + #[test] + fn test_parse_graphql_response_null_data_with_errors_is_fatal() { + let body = json!({ + "data": null, + "errors": [{"message": "fatal"}] + }) + .to_string(); + let result = parse_graphql_response(&body); + assert!(result.is_err(), "null data + errors should be fatal"); + } + + #[test] + fn test_parse_graphql_response_unwraps_single_field() { + let body = json!({ + "data": {"issues": {"nodes": [{"id": "i1"}]}} + }) + .to_string(); + let result = parse_graphql_response(&body).unwrap(); + let val: Value = serde_json::from_str(&result).unwrap(); + assert!(val.get("nodes").is_some(), "should unwrap single-field data envelope"); + } + + // ----------------------------------------------------------------------- + // extract_graphql_cursor + // ----------------------------------------------------------------------- + + #[test] + fn test_extract_graphql_cursor_returns_cursor_when_has_next() { + let body = json!({ + "nodes": [], + "pageInfo": {"hasNextPage": true, "endCursor": "cursor-abc"} + }) + .to_string(); + let cursor = extract_graphql_cursor(&body); + assert_eq!(cursor, Some("cursor-abc".to_string())); + } + + #[test] + fn test_extract_graphql_cursor_returns_none_when_no_next() { + let body = json!({ + "nodes": [], + "pageInfo": {"hasNextPage": false, "endCursor": "cursor-abc"} + }) + .to_string(); + assert_eq!(extract_graphql_cursor(&body), None); + } + + #[test] + fn test_extract_graphql_cursor_returns_none_when_no_page_info() { + let body = json!({"nodes": []}).to_string(); + assert_eq!(extract_graphql_cursor(&body), None); + } + + // ----------------------------------------------------------------------- + // set_nested_value + // ----------------------------------------------------------------------- + + #[test] + fn test_set_nested_value_flat() { + let mut obj = Map::new(); + set_nested_value(&mut obj, "name", Value::String("alice".to_string())); + assert_eq!(obj["name"], "alice"); + } + + #[test] + fn test_set_nested_value_two_levels() { + let mut obj = Map::new(); + set_nested_value( + &mut obj, + "dateRange.start", + Value::String("2024-01-01".to_string()), + ); + set_nested_value( + &mut obj, + "dateRange.end", + Value::String("2024-12-31".to_string()), + ); + let date_range = obj["dateRange"].as_object().unwrap(); + assert_eq!(date_range["start"], "2024-01-01"); + assert_eq!(date_range["end"], "2024-12-31"); + } + + #[test] + fn test_set_nested_value_three_levels() { + let mut obj = Map::new(); + set_nested_value(&mut obj, "a.b.c", Value::String("deep".to_string())); + assert_eq!(obj["a"]["b"]["c"], "deep"); + } + + // ----------------------------------------------------------------------- + // build_graphql_body + // ----------------------------------------------------------------------- + + #[test] + fn test_build_graphql_body_injects_cursor_when_after_arg_present() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "nodes".to_string(), + default_selection: "{ nodes { id } pageInfo { hasNextPage endCursor } }".to_string(), + args: vec![ + GraphQLArgDef { + name: "first".to_string(), + flag_key: "first".to_string(), + gql_type: "Int".to_string(), + is_input: false, + is_list: false, + }, + GraphQLArgDef { + name: "after".to_string(), + flag_key: "after".to_string(), + gql_type: "String".to_string(), + is_input: false, + is_list: false, + }, + ], + }; + let params = Map::new(); + let method_params: HashMap = HashMap::new(); + + let body = + build_graphql_body(&gql, ¶ms, None, &method_params, Some("cursor-xyz")).unwrap(); + let vars = body["variables"].as_object().unwrap(); + assert_eq!(vars.get("after").and_then(|v| v.as_str()), Some("cursor-xyz")); + assert!(body["query"].as_str().unwrap().contains("$after: String")); + } + + #[test] + fn test_build_graphql_body_no_cursor_when_no_after_arg() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "node".to_string(), + default_selection: "{ id name }".to_string(), + args: vec![GraphQLArgDef { + name: "id".to_string(), + flag_key: "id".to_string(), + gql_type: "String!".to_string(), + is_input: false, + is_list: false, + }], + }; + let mut params = Map::new(); + params.insert("id".to_string(), Value::String("n1".to_string())); + let method_params: HashMap = HashMap::new(); + + let body = + build_graphql_body(&gql, ¶ms, None, &method_params, Some("cursor-xyz")).unwrap(); + let vars = body["variables"].as_object().unwrap(); + assert!(vars.get("after").is_none(), "no after arg = cursor not injected"); + } + + #[test] + fn test_build_graphql_body_reconstructs_nested_input() { + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "filteredNodes".to_string(), + default_selection: "{ nodes { id } }".to_string(), + args: vec![GraphQLArgDef { + name: "filter".to_string(), + flag_key: "filter".to_string(), + gql_type: "NodeFilter".to_string(), + is_input: true, + is_list: false, + }], + }; + + let mut params = Map::new(); + params.insert( + "date-range-start".to_string(), + Value::String("2024-01-01".to_string()), + ); + params.insert( + "date-range-end".to_string(), + Value::String("2024-12-31".to_string()), + ); + + let mut method_params: HashMap = HashMap::new(); + method_params.insert( + "date-range-start".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("dateRange.start".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "date-range-end".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("dateRange.end".to_string()), + ..Default::default() + }, + ); + + let body = build_graphql_body(&gql, ¶ms, None, &method_params, None).unwrap(); + let filter = body["variables"]["filter"].as_object().unwrap(); + let date_range = filter["dateRange"].as_object().unwrap(); + assert_eq!(date_range["start"], "2024-01-01"); + assert_eq!(date_range["end"], "2024-12-31"); + } + + // ----------------------------------------------------------------------- + // JFL-1.2: object shorthand + mutually exclusive input modes + // ----------------------------------------------------------------------- + + #[test] + fn test_coerce_graphql_value_object_parses_json() { + // JFL-1.2: when a flag's param_type=="object" (an input-shorthand + // flag), the CLI string must be JSON-parsed so it lands in the + // GraphQL variables as an object, not a quoted string. + let mp = MethodParameter { + param_type: Some("object".to_string()), + ..Default::default() + }; + let v = coerce_graphql_value( + &Value::String(r#"{"first":"Abe","last":"Lincoln"}"#.to_string()), + Some(&mp), + ) + .unwrap(); + assert_eq!(v, json!({ "first": "Abe", "last": "Lincoln" })); + } + + #[test] + fn test_coerce_graphql_value_object_invalid_json_is_validation_error() { + // Malformed object-shorthand JSON must surface as a loud Validation + // error rather than a best-effort fallback to a string. + let mp = MethodParameter { + param_type: Some("object".to_string()), + ..Default::default() + }; + let err = coerce_graphql_value( + &Value::String("not json".to_string()), + Some(&mp), + ) + .unwrap_err(); + match err { + CliError::Validation(_) => {} + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_coerce_graphql_value_object_non_object_json_is_validation_error() { + // Object-shorthand expects a JSON *object*. Valid JSON of any other + // shape (number, array, string, bool, null) must be rejected with a + // clear error so the user doesn't end up with a double-nested or + // type-mismatched GraphQL variable. + let mp = MethodParameter { + param_type: Some("object".to_string()), + ..Default::default() + }; + for bad in [r#"42"#, r#"[1,2]"#, r#""hello""#, r#"true"#, r#"null"#] { + let err = coerce_graphql_value(&Value::String(bad.to_string()), Some(&mp)) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("must be a JSON object"), + "expected 'must be a JSON object' in error for {bad}: {msg}" + ); + } + other => panic!("expected Validation error for {bad}, got {other:?}"), + } + } + + // Pre-decoded non-String values (the `--params` JSON path inserts + // arbitrary Value shapes directly) must also be shape-validated so + // they don't silently drop in build_graphql_body's Value::Object guard. + for (pre_decoded, kind) in [ + (json!([1, 2, 3]), "array"), + (json!(42), "number"), + (json!(true), "boolean"), + (Value::Null, "null"), + ] { + let err = coerce_graphql_value(&pre_decoded, Some(&mp)).unwrap_err(); + match err { + CliError::Validation(msg) => assert!( + msg.contains("must be a JSON object") && msg.contains(kind), + "expected 'must be a JSON object, got {kind}' for pre-decoded {pre_decoded}: {msg}" + ), + other => panic!("expected Validation error for pre-decoded {pre_decoded}, got {other:?}"), + } + } + } + + #[test] + fn test_graphql_json_plus_input_arg_validation_error() { + // JFL-1.2: passing `--json` alongside any input-arg flag is a + // validation error. Mirrors the OpenAPI rule. + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "filteredNodes".to_string(), + default_selection: "{ nodes { id } }".to_string(), + args: vec![GraphQLArgDef { + name: "filter".to_string(), + flag_key: "filter".to_string(), + gql_type: "NodeFilter".to_string(), + is_input: true, + is_list: false, + }], + }; + + let mut params = Map::new(); + params.insert( + "name".to_string(), + Value::String("Abraham".to_string()), + ); + + let mut method_params: HashMap = HashMap::new(); + method_params.insert( + "name".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("name".to_string()), + ..Default::default() + }, + ); + + let err = build_graphql_body( + &gql, + ¶ms, + Some(r#"{"name":"from-json"}"#), + &method_params, + None, + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--json"), "error must mention --json: {msg}"); + assert!(msg.contains("--name"), "error must name the input flag: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_graphql_object_shorthand_plus_leaf_validation_error() { + // JFL-1.2: `--filter` (object shorthand) and `--filter.name` (leaf) + // must not be combined. + let gql = GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "filteredNodes".to_string(), + default_selection: "{ nodes { id } }".to_string(), + args: vec![GraphQLArgDef { + name: "filter".to_string(), + flag_key: "filter".to_string(), + gql_type: "NodeFilter".to_string(), + is_input: true, + is_list: false, + }], + }; + + let mut params = Map::new(); + params.insert( + "filter".to_string(), + Value::String(r#"{"other":"x"}"#.to_string()), + ); + params.insert( + "filter.name".to_string(), + Value::String("Abraham".to_string()), + ); + + let mut method_params: HashMap = HashMap::new(); + method_params.insert( + "filter".to_string(), + MethodParameter { + param_type: Some("object".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some(String::new()), + ..Default::default() + }, + ); + method_params.insert( + "filter.name".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + graphql_input_arg: Some("filter".to_string()), + graphql_field_path: Some("name".to_string()), + ..Default::default() + }, + ); + + let err = build_graphql_body(&gql, ¶ms, None, &method_params, None).unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--filter"), "error must mention --filter: {msg}"); + assert!( + msg.contains("--filter.name"), + "error must mention --filter.name: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } +} diff --git a/src/graphql/help.rs b/src/graphql/help.rs new file mode 100644 index 0000000..81a843f --- /dev/null +++ b/src/graphql/help.rs @@ -0,0 +1,383 @@ +//! Spec output — renders the CLI's command surface as a machine-readable +//! JSON document. Backs the `--schema` global flag, which is the agent-facing +//! counterpart to `--help`: wherever a user could type `--help` for prose, +//! they can type `--schema` for the same scope rendered as JSON. + +use serde_json::{json, Map, Value}; + +use crate::graphql::discovery::{GraphQLOperation, GraphQLResource, GraphQLSchema}; + +/// Build the spec document for the given subcommand path. +/// +/// Returns `Some(value)` when the path resolves in this doc and `None` when it +/// doesn't (so a multi-binding caller can try the next binding). Empty path +/// always returns `Some(_)` — every binding contributes its full operation +/// list to the aggregate root view. +pub fn build_schema(doc: &GraphQLSchema, path: &[String]) -> Option { + match path.len() { + 0 => Some(list_all_operations(doc)), + 1 => list_resource_operations(doc, &path[0]), + _ => { + let resource_path: Vec<&str> = + path[..path.len() - 1].iter().map(|s| s.as_str()).collect(); + let method_name = path[path.len() - 1].as_str(); + operation_schema(doc, &resource_path, method_name).or_else(|| { + let full_path: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + list_nested_resource_operations(doc, &full_path) + }) + } + } +} + +fn list_all_operations(doc: &GraphQLSchema) -> Value { + let mut ops: Vec = Vec::new(); + let mut names: Vec<_> = doc.resources.keys().collect(); + names.sort(); + for name in names { + collect_resource_ops(&doc.resources[name], &[name], &mut ops); + } + json!(ops) +} + +fn list_resource_operations(doc: &GraphQLSchema, resource: &str) -> Option { + let res = doc.resources.get(resource)?; + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, &[resource], &mut ops); + Some(json!(ops)) +} + +fn list_nested_resource_operations(doc: &GraphQLSchema, path: &[&str]) -> Option { + let first = path.first()?; + let mut res = doc.resources.get(*first)?; + for segment in &path[1..] { + res = res.resources.get(*segment)?; + } + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, path, &mut ops); + Some(json!(ops)) +} + +fn operation_schema(doc: &GraphQLSchema, resource_path: &[&str], method_name: &str) -> Option { + let first = resource_path.first()?; + let mut res = doc.resources.get(*first)?; + for segment in &resource_path[1..] { + res = res.resources.get(*segment)?; + } + let method = res.methods.get(method_name)?; + Some(build_operation_schema(resource_path, method_name, method)) +} + +fn build_operation_schema(resource_path: &[&str], method_name: &str, method: &GraphQLOperation) -> Value { + let mut properties: Map = Map::new(); + let mut required: Vec = Vec::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for name in param_names { + let param = &method.parameters[name]; + let mut prop = json!({ + "type": param.param_type.as_deref().unwrap_or("string"), + "description": param.description.as_deref().unwrap_or(""), + }); + if let Some(enums) = ¶m.enum_values { + prop["enum"] = json!(enums); + } + if param.required { + required.push(name.clone()); + } + properties.insert(name.clone(), prop); + } + required.sort(); + + // Per ADR-0006: `--schema` is the agent-facing contract. Drop + // GraphQL execution detail (`operationType`, `field`) — agents drive + // the CLI, not the underlying GraphQL schema. Rename `parameters` → + // `input` for symmetry with `output` / `defaultSelection`. + // + // `output` is OpenAPI-only — the GraphQL IR has no lowered return- + // type schema yet. Instead we emit `defaultSelection`, the GraphQL + // fragment string the CLI will send by default; it tells the agent + // which fields it will receive without overpromising a JSON Schema. + let mut out = json!({ + "operation": format!("{}.{}", resource_path.join("."), method_name), + "description": method.description.as_deref().unwrap_or(""), + "input": { + "type": "object", + "properties": properties, + "required": required, + }, + }); + if let Some(default_selection) = method + .graphql + .as_ref() + .map(|g| g.default_selection.as_str()) + .filter(|s| !s.is_empty()) + { + out["defaultSelection"] = json!(default_selection); + } + out +} + +fn collect_resource_ops(res: &GraphQLResource, path: &[&str], ops: &mut Vec) { + let mut method_names: Vec<_> = res.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let m = &res.methods[method_name]; + // Per ADR-0006: drop `operationType` and `field` — GraphQL + // execution detail an agent driving the CLI never uses. + ops.push(json!({ + "operation": format!("{}.{}", path.join("."), method_name), + "description": m.description.as_deref().unwrap_or(""), + })); + } + let mut sub_names: Vec<_> = res.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let mut sub_path = path.to_vec(); + sub_path.push(sub_name); + collect_resource_ops(&res.resources[sub_name], &sub_path, ops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graphql::discovery::{MethodParameter, GraphQLOperation, GraphQLResource}; + use std::collections::HashMap; + + fn make_doc() -> GraphQLSchema { + use crate::graphql::discovery::GraphQLMethodInfo; + + let mut params = HashMap::new(); + params.insert( + "user_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user ID".to_string()), + required: true, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + GraphQLOperation { + description: Some("Get a user".to_string()), + parameters: params, + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "user".to_string(), + default_selection: "{ id name }".to_string(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + GraphQLResource { + methods, + resources: HashMap::new(), + }, + ); + GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_render_root_lists_all() { + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_resource() { + let doc = make_doc(); + let output = list_resource_operations(&doc, "users").unwrap(); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_operation_schema() { + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + // Per ADR-0006: drop GraphQL execution detail; rename + // `parameters` → `input`. + assert!(schema.get("operationType").is_none(), "operationType should be dropped"); + assert!(schema.get("field").is_none(), "field should be dropped"); + assert!(schema.get("parameters").is_none(), "`parameters` should be renamed to `input`"); + let required = schema["input"]["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "user_id")); + } + + #[test] + fn test_default_selection_emitted_on_per_op_schema() { + // Per ADR-0006: GraphQL ops carry `defaultSelection` as a + // sibling of `input` — the GraphQL fragment string telling the + // agent which fields it will get back by default. + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + assert_eq!(schema["defaultSelection"], "{ id name }"); + } + + #[test] + fn test_default_selection_omitted_when_empty() { + use crate::graphql::discovery::{ + GraphQLMethodInfo, GraphQLOperation, GraphQLResource, MethodParameter, + }; + let mut methods = HashMap::new(); + methods.insert( + "ping".to_string(), + GraphQLOperation { + description: Some("Ping".to_string()), + parameters: HashMap::::new(), + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "ping".to_string(), + default_selection: String::new(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "ops".to_string(), + GraphQLResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + let schema = operation_schema(&doc, &["ops"], "ping").unwrap(); + assert!( + schema.get("defaultSelection").is_none(), + "empty default_selection should be omitted: {schema}", + ); + } + + #[test] + fn test_render_schema_nested_sub_resource_listing() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation::default(), + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into()]; + let result = build_schema(&doc, &path); + assert!(result.is_some(), "sub-resource path should list operations, not be None"); + } + + #[test] + fn test_render_nested_operation_schema() { + use crate::graphql::discovery::GraphQLMethodInfo; + + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation { + description: Some("Get a membership".to_string()), + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: "membership".to_string(), + default_selection: "{ id }".to_string(), + args: Vec::new(), + }), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let schema = operation_schema(&doc, &["organizations", "memberships"], "get-membership").unwrap(); + assert_eq!(schema["operation"], "organizations.memberships.get-membership"); + } + + #[test] + fn test_render_schema_dispatches_nested_path() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::graphql::discovery::GraphQLOperation::default(), + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + GraphQLResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + GraphQLResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = GraphQLSchema { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into(), "get-membership".into()]; + let result = build_schema(&doc, &path); + assert!(result.is_some(), "nested path should resolve correctly"); + } +} diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs new file mode 100644 index 0000000..cd021be --- /dev/null +++ b/src/graphql/mod.rs @@ -0,0 +1,12 @@ +mod app; +mod binding; +pub mod commands; +mod help; +pub mod executor; +mod parser; +pub mod discovery; + +pub use self::app::{AppContext, resolve_method_from_matches}; +pub(crate) use self::app::CliApp; +pub use self::binding::GraphqlBinding; +pub use self::parser::load_graphql_schema; diff --git a/src/graphql/parser.rs b/src/graphql/parser.rs new file mode 100644 index 0000000..f97056f --- /dev/null +++ b/src/graphql/parser.rs @@ -0,0 +1,1081 @@ +//! GraphQL Introspection JSON Parser +//! +//! Converts a GraphQL introspection JSON schema into the internal `GraphQLSchema` +//! used by the CLI command builder and executor. +//! +//! Input format: `{"data": {"__schema": {...}}}` (standard introspection response) +//! or `{"__schema": {...}}` (bare schema). +//! +//! Use `src/bin/strip_schema.rs` to remove descriptions and built-in meta-types +//! before checking in a schema file. + +use serde_json::Value; +use std::collections::HashMap; + +use crate::graphql::discovery::{ + GraphQLArgDef, GraphQLMethodInfo, MethodParameter, GraphQLSchema, GraphQLOperation, GraphQLResource, +}; +use crate::error::CliError; + +/// GraphQL built-in scalar type names. +const BUILTIN_SCALARS: &[&str] = &["String", "Int", "Float", "Boolean", "ID"]; + +/// Known suffixes for mutations, used to split into resource + method name. +const MUTATION_SUFFIXES: &[&str] = &[ + "Unarchive", + "Archive", + "Create", + "Update", + "Delete", + "Remove", + "Connect", + "Disconnect", + "Import", + "Rotate", + "Accept", + "Decline", + "Leave", + "Join", + "Resume", + "Pause", + "Suspend", + "Unsuspend", + "Mark", +]; + +/// Load a GraphQL introspection JSON schema and convert it into a `GraphQLSchema`. +/// +/// Accepts either the full introspection response (`{"data": {"__schema": ...}}`) +/// or the bare schema object (`{"__schema": ...}`). +pub fn load_graphql_schema( + introspection_json: &str, + cli_name: &str, + endpoint: &str, +) -> Result { + let data: Value = serde_json::from_str(introspection_json) + .map_err(|e| CliError::Discovery(format!("Failed to parse introspection JSON: {e}")))?; + + // Support both wrapped and bare introspection responses. + let schema = if data.get("data").is_some() { + &data["data"]["__schema"] + } else { + &data["__schema"] + }; + + let types = schema["types"] + .as_array() + .ok_or_else(|| CliError::Discovery("Missing 'types' array in schema".to_string()))?; + + let mut object_types: HashMap<&str, &Value> = HashMap::new(); + let mut input_types: HashMap<&str, &Value> = HashMap::new(); + let mut enum_types: HashMap<&str, &Value> = HashMap::new(); + let mut scalar_names: Vec = BUILTIN_SCALARS.iter().map(|s| s.to_string()).collect(); + + for ty in types { + let kind = ty["kind"].as_str().unwrap_or(""); + let name = match ty["name"].as_str() { + Some(n) if !n.starts_with("__") => n, + _ => continue, + }; + match kind { + "OBJECT" => { + object_types.insert(name, ty); + } + "INPUT_OBJECT" => { + input_types.insert(name, ty); + } + "ENUM" => { + enum_types.insert(name, ty); + } + "SCALAR" => { + scalar_names.push(name.to_string()); + } + _ => {} + } + } + + let query_type_name = schema["queryType"]["name"].as_str().unwrap_or("Query"); + let mutation_type_name = schema["mutationType"]["name"].as_str(); + + let mut resources: HashMap = HashMap::new(); + let empty_args: Vec = Vec::new(); + + // Process Query fields + if let Some(query_type) = object_types.get(query_type_name) { + let fields = query_type["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let field_name = match field["name"].as_str() { + Some(n) if !n.starts_with('_') => n, + _ => continue, + }; + let return_type_name = unwrap_type_name(&field["type"]); + let (resource_name, method_name) = split_query_name(field_name, &return_type_name); + let args = field["args"].as_array().unwrap_or(&empty_args); + let (parameters, arg_defs) = + build_parameters_from_args(args, &input_types, &enum_types, &scalar_names); + let default_selection = + build_default_selection(&return_type_name, &object_types, &scalar_names); + + let method = GraphQLOperation { + id: Some(format!("{resource_name}.{method_name}")), + description: desc(field), + parameters, + graphql: Some(GraphQLMethodInfo { + operation_type: "query".to_string(), + field_name: field_name.to_string(), + default_selection, + args: arg_defs, + }), + ..Default::default() + }; + resources.entry(resource_name).or_default().methods.insert(method_name, method); + } + } + + // Process Mutation fields + if let Some(mt_name) = mutation_type_name { + if let Some(mutation_type) = object_types.get(mt_name) { + let fields = mutation_type["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let field_name = match field["name"].as_str() { + Some(n) if !n.starts_with('_') => n, + _ => continue, + }; + let return_type_name = unwrap_type_name(&field["type"]); + let (resource_name, method_name) = split_mutation_name(field_name); + let args = field["args"].as_array().unwrap_or(&empty_args); + let (parameters, arg_defs) = + build_parameters_from_args(args, &input_types, &enum_types, &scalar_names); + let default_selection = + build_default_selection(&return_type_name, &object_types, &scalar_names); + + let method = GraphQLOperation { + id: Some(format!("{resource_name}.{method_name}")), + description: desc(field), + parameters, + graphql: Some(GraphQLMethodInfo { + operation_type: "mutation".to_string(), + field_name: field_name.to_string(), + default_selection, + args: arg_defs, + }), + ..Default::default() + }; + resources.entry(resource_name).or_default().methods.insert(method_name, method); + } + } + } + + Ok(GraphQLSchema { + name: cli_name.to_string(), + version: "1".to_string(), + root_url: endpoint.to_string(), + resources, + ..Default::default() + }) +} + +/// Extract an optional description string from a JSON node. +fn desc(val: &Value) -> Option { + val.get("description") + .and_then(|d| d.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn default_val(val: &Value) -> Option { + val.get("defaultValue") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn enum_values(enum_def: &Value) -> Vec { + enum_def["enumValues"] + .as_array() + .map(|ev| { + ev.iter() + .filter_map(|v| v["name"].as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +/// Follow NON_NULL/LIST wrappers to find the named type. +fn unwrap_type_name(ty: &Value) -> String { + match ty["kind"].as_str().unwrap_or("") { + "NON_NULL" | "LIST" => unwrap_type_name(&ty["ofType"]), + _ => ty["name"].as_str().unwrap_or("String").to_string(), + } +} + +/// True when the outermost wrapper is NON_NULL. +fn is_non_null(ty: &Value) -> bool { + ty["kind"].as_str() == Some("NON_NULL") +} + +/// True when the type wraps (at any outer level) a LIST. We descend through +/// NON_NULL wrappers — `[T!]`, `[T!]!`, `[T]` all count as list types. +fn is_list_type(ty: &Value) -> bool { + match ty["kind"].as_str().unwrap_or("") { + "LIST" => true, + "NON_NULL" => is_list_type(&ty["ofType"]), + _ => false, + } +} + +/// Reconstruct the type string including nullability, e.g. `"String!"`, `"[ID!]!"`. +fn gql_type_string(ty: &Value) -> String { + match ty["kind"].as_str().unwrap_or("") { + "NON_NULL" => format!("{}!", gql_type_string(&ty["ofType"])), + "LIST" => format!("[{}]", gql_type_string(&ty["ofType"])), + _ => ty["name"].as_str().unwrap_or("String").to_string(), + } +} + +/// Check if a type name is a known scalar. +fn is_scalar(name: &str, scalar_names: &[String]) -> bool { + scalar_names.iter().any(|s| s == name) +} + +/// Split a query field name into (resource_name, method_name). +fn split_query_name(field_name: &str, return_type: &str) -> (String, String) { + let kebab = camel_to_kebab(field_name); + + // "For" pattern: attachmentsForURL → (attachment, list-for-url) + if let Some(pos) = field_name.find("For") { + if pos > 0 { + let prefix = &field_name[..pos]; + let suffix = &field_name[pos..]; + let resource = camel_to_kebab(&singular_camel(prefix)); + let method = format!("list-{}", camel_to_kebab(suffix).to_lowercase()); + return (resource, method); + } + } + + // Connection return type is authoritative — always a list + if return_type.ends_with("Connection") { + return (singular_kebab(&kebab), "list".to_string()); + } + + // Plural field name heuristic + if field_name.ends_with('s') + && !field_name.ends_with("ss") + && !field_name.ends_with("us") + && !field_name.ends_with("Status") + && field_name.len() > 2 + { + return (singular_kebab(&kebab), "list".to_string()); + } + + (kebab, "get".to_string()) +} + +/// Split a mutation field name into (resource_name, method_name). +fn split_mutation_name(field_name: &str) -> (String, String) { + for suffix in MUTATION_SUFFIXES { + if field_name.ends_with(suffix) && field_name.len() > suffix.len() { + let prefix = &field_name[..field_name.len() - suffix.len()]; + return (camel_to_kebab(prefix), suffix.to_lowercase()); + } + } + if let Some((resource, action)) = split_at_second_word(field_name) { + return (camel_to_kebab(&resource), camel_to_kebab(&action)); + } + (camel_to_kebab(field_name), "execute".to_string()) +} + +fn split_at_second_word(name: &str) -> Option<(String, String)> { + let chars: Vec = name.chars().collect(); + for i in 1..chars.len() { + if chars[i].is_uppercase() { + let prefix = &name[..i]; + let suffix = &name[i..]; + if prefix.len() > 2 { + return Some((prefix.to_string(), suffix.to_string())); + } + } + } + None +} + +fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + let chars: Vec = s.chars().collect(); + for (i, &ch) in chars.iter().enumerate() { + if ch.is_uppercase() { + if i > 0 + && (chars[i - 1].is_lowercase() + || (i + 1 < chars.len() && chars[i + 1].is_lowercase())) + { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + result +} + +fn singular_kebab(kebab: &str) -> String { + let (prefix, last) = match kebab.rfind('-') { + Some(pos) => (&kebab[..pos + 1], &kebab[pos + 1..]), + None => ("", kebab), + }; + format!("{prefix}{}", singular_word(last)) +} + +fn singular_word(word: &str) -> String { + if let Some(stem) = word.strip_suffix("ies") { + if stem.len() >= 2 { + return format!("{stem}y"); + } + } + if word.ends_with("xes") + || word.ends_with("ches") + || word.ends_with("shes") + || word.ends_with("sses") + || word.ends_with("zzes") + { + if let Some(stem) = word.strip_suffix("es") { + if stem.len() >= 2 { + return stem.to_string(); + } + } + } + if let Some(stem) = word.strip_suffix('s') { + // Block Latin/Greek singulars that end in -us and must not be stripped. + const LATIN_SINGULARS: &[&str] = &[ + "status", "bonus", "campus", "census", "focus", + "nexus", "radius", "virus", "alias", + ]; + if stem.len() >= 3 && !stem.ends_with('s') && !LATIN_SINGULARS.contains(&word) { + return stem.to_string(); + } + } + word.to_string() +} + +fn singular_camel(name: &str) -> String { + singular_kebab(&camel_to_kebab(name)) +} + +fn param_to_flag_name(name: &str) -> String { + camel_to_kebab(name) +} + +/// Map GraphQL scalar type names to CLI param type strings. +fn graphql_type_to_param_type(type_name: &str) -> String { + match type_name { + "Int" => "integer".to_string(), + "Float" => "number".to_string(), + "Boolean" => "boolean".to_string(), + _ => "string".to_string(), + } +} + +/// Build CLI parameters and `GraphQLArgDef` list from introspection field arguments. +fn build_parameters_from_args( + args: &[Value], + input_types: &HashMap<&str, &Value>, + enum_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> (HashMap, Vec) { + let mut params = HashMap::new(); + let mut arg_defs = Vec::new(); + + let is_known_complex = + |name: &str| input_types.contains_key(name) || enum_types.contains_key(name); + + for arg in args { + let arg_name = match arg["name"].as_str() { + Some(n) => n, + None => continue, + }; + let type_name = unwrap_type_name(&arg["type"]); + let is_required = is_non_null(&arg["type"]); + let flag_key = param_to_flag_name(arg_name); + + if is_scalar(&type_name, scalar_names) || !is_known_complex(&type_name) { + params.insert( + flag_key.clone(), + MethodParameter { + param_type: Some(graphql_type_to_param_type(&type_name)), + description: desc(arg), + required: is_required, + default: default_val(arg), + ..Default::default() + }, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: false, + is_list: is_list_type(&arg["type"]), + }); + } else if let Some(enum_def) = enum_types.get(type_name.as_str()) { + let values = enum_values(enum_def); + params.insert( + flag_key.clone(), + MethodParameter { + param_type: Some("string".to_string()), + description: desc(arg), + required: is_required, + default: default_val(arg), + enum_values: Some(values), + ..Default::default() + }, + ); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: false, + is_list: is_list_type(&arg["type"]), + }); + } else if input_types.contains_key(type_name.as_str()) { + flatten_input_type( + &type_name, + arg_name, + "", + "", + is_required, + input_types, + enum_types, + scalar_names, + &mut params, + 0, + ); + // JFL-1.4: also emit an object-shorthand flag for the whole input + // arg, mirroring the OpenAPI body parent emission. Users can pass + // `--filter '{"query":"x"}'` as an alternative to `--query x`. + // `required: false` at the CLI level — a required input may be + // satisfied via per-field flags instead. `graphql_field_path` is + // empty: the executor merges the parsed object into the input + // map at the top level. `or_insert` so a same-named leaf flag + // (pathological) wins. + params + .entry(flag_key.clone()) + .or_insert(MethodParameter { + param_type: Some("object".to_string()), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(String::new()), + description: desc(arg), + required: false, + ..Default::default() + }); + arg_defs.push(GraphQLArgDef { + name: arg_name.to_string(), + flag_key, + gql_type: gql_type_string(&arg["type"]), + is_input: true, + is_list: is_list_type(&arg["type"]), + }); + } + } + + (params, arg_defs) +} + +const MAX_INPUT_DEPTH: u8 = 3; + +#[allow(clippy::too_many_arguments)] +fn flatten_input_type( + type_name: &str, + arg_name: &str, + field_path: &str, + flag_prefix: &str, + parent_required: bool, + input_types: &HashMap<&str, &Value>, + enum_types: &HashMap<&str, &Value>, + scalar_names: &[String], + params: &mut HashMap, + depth: u8, +) { + if depth >= MAX_INPUT_DEPTH { + return; + } + let input_def = match input_types.get(type_name) { + Some(d) => d, + None => return, + }; + let input_fields = match input_def["inputFields"].as_array() { + Some(f) => f, + None => return, + }; + + for input_field in input_fields { + let field_name = match input_field["name"].as_str() { + Some(n) => n, + None => continue, + }; + let field_type_name = unwrap_type_name(&input_field["type"]); + let field_required = parent_required && is_non_null(&input_field["type"]); + + let field_flag = param_to_flag_name(field_name); + let full_flag = if flag_prefix.is_empty() { + field_flag + } else { + format!("{flag_prefix}.{}", param_to_flag_name(field_name)) + }; + let full_path = if field_path.is_empty() { + field_name.to_string() + } else { + format!("{field_path}.{field_name}") + }; + + if is_scalar(&field_type_name, scalar_names) { + params.insert( + full_flag, + MethodParameter { + param_type: Some(graphql_type_to_param_type(&field_type_name)), + description: desc(input_field), + required: field_required, + default: default_val(input_field), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + ..Default::default() + }, + ); + } else if let Some(enum_def) = enum_types.get(field_type_name.as_str()) { + let values = enum_values(enum_def); + params.insert( + full_flag, + MethodParameter { + param_type: Some("string".to_string()), + description: desc(input_field), + required: field_required, + default: default_val(input_field), + enum_values: Some(values), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + }, + ); + } else if input_types.contains_key(field_type_name.as_str()) { + flatten_input_type( + &field_type_name, + arg_name, + &full_path, + &full_flag, + field_required, + input_types, + enum_types, + scalar_names, + params, + depth + 1, + ); + params.entry(full_flag.clone()).or_insert(MethodParameter { + param_type: Some("object".to_string()), + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path.clone()), + description: desc(input_field), + required: false, + ..Default::default() + }); + } else { + // Undeclared custom scalar — treat as string + params.insert( + full_flag, + MethodParameter { + param_type: Some("string".to_string()), + description: desc(input_field), + required: field_required, + graphql_input_arg: Some(arg_name.to_string()), + graphql_field_path: Some(full_path), + ..Default::default() + }, + ); + } + } +} + +/// Build a default selection set for a GraphQL return type. +fn build_default_selection( + type_name: &str, + object_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> String { + if type_name.ends_with("Connection") { + let node_type = type_name.strip_suffix("Connection").unwrap(); + let node_selection = build_scalar_fields(node_type, object_types, scalar_names); + let nodes_part = if node_selection.is_empty() { + "nodes { id }".to_string() + } else { + format!("nodes {{ {node_selection} }}") + }; + return format!("{{ {nodes_part} pageInfo {{ hasNextPage endCursor }} }}"); + } + + if type_name.ends_with("Payload") { + if let Some(obj) = object_types.get(type_name) { + let mut parts = Vec::new(); + let fields = obj["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + for field in fields { + let args = field["args"].as_array().map(|a| a.len()).unwrap_or(0); + if args > 0 { + continue; + } + let field_name = field["name"].as_str().unwrap_or(""); + let ft = unwrap_type_name(&field["type"]); + if is_scalar(&ft, scalar_names) { + parts.push(field_name.to_string()); + } else if object_types.contains_key(ft.as_str()) { + let inner = build_scalar_fields(&ft, object_types, scalar_names); + if !inner.is_empty() { + parts.push(format!("{field_name} {{ {inner} }}")); + } + } + } + if parts.is_empty() { + return "{ success }".to_string(); + } + return format!("{{ {} }}", parts.join(" ")); + } + } + + let fields = build_scalar_fields(type_name, object_types, scalar_names); + if fields.is_empty() { + return "{ id }".to_string(); + } + format!("{{ {fields} }}") +} + +/// Build a space-separated scalar field selection for a type. +fn build_scalar_fields( + type_name: &str, + object_types: &HashMap<&str, &Value>, + scalar_names: &[String], +) -> String { + let obj = match object_types.get(type_name) { + Some(o) => o, + None => return String::new(), + }; + let fields = match obj["fields"].as_array() { + Some(f) => f, + None => return String::new(), + }; + + let mut parts: Vec = Vec::new(); + for f in fields { + let args_len = f["args"].as_array().map(|a| a.len()).unwrap_or(0); + if args_len > 0 { + continue; + } + let field_name = f["name"].as_str().unwrap_or(""); + let ft = unwrap_type_name(&f["type"]); + if is_scalar(&ft, scalar_names) { + parts.push(field_name.to_string()); + } else if !ft.ends_with("Connection") { + if let Some(inner_obj) = object_types.get(ft.as_str()) { + let inner_fields = inner_obj["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]); + let inner_scalars: Vec<&str> = inner_fields + .iter() + .filter(|if_| { + if_["args"].as_array().map(|a| a.len()).unwrap_or(0) == 0 + && is_scalar(&unwrap_type_name(&if_["type"]), scalar_names) + }) + .filter_map(|if_| if_["name"].as_str()) + .collect(); + if !inner_scalars.is_empty() { + parts.push(format!("{field_name} {{ {} }}", inner_scalars.join(" "))); + } + } + } + } + + parts.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // --------------------------------------------------------------------------- + // Naming utility tests (no schema needed) + // --------------------------------------------------------------------------- + + #[test] + fn test_camel_to_kebab() { + assert_eq!(camel_to_kebab("issueCreate"), "issue-create"); + assert_eq!(camel_to_kebab("issue"), "issue"); + assert_eq!(camel_to_kebab("customView"), "custom-view"); + assert_eq!(camel_to_kebab("attachmentsForURL"), "attachments-for-url"); + assert_eq!(camel_to_kebab("teamMembershipCreate"), "team-membership-create"); + } + + #[test] + fn test_singular_kebab() { + assert_eq!(singular_kebab("issues"), "issue"); + assert_eq!(singular_kebab("activities"), "activity"); + assert_eq!(singular_kebab("gift-card-activities"), "gift-card-activity"); + assert_eq!(singular_kebab("boxes"), "box"); + assert_eq!(singular_kebab("watches"), "watch"); + assert_eq!(singular_kebab("programs"), "program"); + assert_eq!(singular_kebab("loyalty-programs"), "loyalty-program"); + assert_eq!(singular_kebab("sms"), "sms"); + assert_eq!(singular_kebab("status"), "status"); + assert_eq!(singular_kebab("menus"), "menu"); + assert_eq!(singular_kebab("gurus"), "guru"); + } + + #[test] + fn test_split_query_name() { + assert_eq!( + split_query_name("issues", "IssueConnection"), + ("issue".to_string(), "list".to_string()) + ); + assert_eq!( + split_query_name("giftCardActivities", "GiftCardActivityConnection"), + ("gift-card-activity".to_string(), "list".to_string()) + ); + assert_eq!( + split_query_name("issue", "Issue"), + ("issue".to_string(), "get".to_string()) + ); + assert_eq!( + split_query_name("attachmentsForURL", "AttachmentConnection"), + ("attachment".to_string(), "list-for-url".to_string()) + ); + } + + #[test] + fn test_split_mutation_name() { + assert_eq!( + split_mutation_name("issueCreate"), + ("issue".to_string(), "create".to_string()) + ); + assert_eq!( + split_mutation_name("issueDelete"), + ("issue".to_string(), "delete".to_string()) + ); + assert_eq!( + split_mutation_name("attachmentLinkSlack"), + ("attachment".to_string(), "link-slack".to_string()) + ); + } + + // --------------------------------------------------------------------------- + // Schema loading helpers + // --------------------------------------------------------------------------- + + /// Shorthand type-ref builders for inline test schemas. + fn nn(inner: Value) -> Value { + json!({"kind": "NON_NULL", "name": null, "ofType": inner}) + } + fn scalar(name: &str) -> Value { + json!({"kind": "SCALAR", "name": name, "ofType": null}) + } + fn obj(name: &str) -> Value { + json!({"kind": "OBJECT", "name": name, "ofType": null}) + } + fn input_obj(name: &str) -> Value { + json!({"kind": "INPUT_OBJECT", "name": name, "ofType": null}) + } + fn list_of(inner: Value) -> Value { + json!({"kind": "LIST", "name": null, "ofType": inner}) + } + + fn make_schema(types: Value) -> String { + json!({ + "data": { + "__schema": { + "queryType": {"name": "Query"}, + "mutationType": {"name": "Mutation"}, + "types": types + } + } + }) + .to_string() + } + + // --------------------------------------------------------------------------- + // Schema loading tests + // --------------------------------------------------------------------------- + + #[test] + fn test_load_minimal_schema() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [ + { + "name": "issue", + "args": [{"name": "id", "type": nn(scalar("String"))}], + "type": nn(obj("Issue")), "isDeprecated": false + }, + { + "name": "issues", + "args": [ + {"name": "first", "type": scalar("Int")}, + {"name": "after", "type": scalar("String")} + ], + "type": nn(obj("IssueConnection")), "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [ + { + "name": "issueCreate", + "args": [{"name": "input", "type": nn(input_obj("IssueCreateInput"))}], + "type": nn(obj("IssuePayload")), "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", "name": "Issue", + "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false}, + {"name": "title", "args": [], "type": nn(scalar("String")), "isDeprecated": false}, + {"name": "description", "args": [], "type": scalar("String"), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "IssueConnection", + "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("Issue")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "PageInfo", + "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ] + }, + { + "kind": "OBJECT", "name": "IssuePayload", + "fields": [ + {"name": "success", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "issue", "args": [], "type": obj("Issue"), "isDeprecated": false} + ] + }, + { + "kind": "INPUT_OBJECT", "name": "IssueCreateInput", + "inputFields": [ + {"name": "title", "type": nn(scalar("String"))}, + {"name": "description", "type": scalar("String")}, + {"name": "teamId", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + assert_eq!(doc.name, "test"); + + let issue = doc.resources.get("issue").expect("issue resource missing"); + assert!(issue.methods.contains_key("get"), "missing get"); + assert!(issue.methods.contains_key("list"), "missing list"); + assert!(issue.methods.contains_key("create"), "missing create"); + + let get = issue.methods.get("get").unwrap(); + assert!(get.parameters.contains_key("id")); + assert!(get.parameters.get("id").unwrap().required); + + let list = issue.methods.get("list").unwrap(); + let list_sel = &list.graphql.as_ref().unwrap().default_selection; + assert!(list_sel.contains("pageInfo"), "list selection missing pageInfo: {list_sel}"); + assert!(list_sel.contains("hasNextPage"), "list selection missing hasNextPage: {list_sel}"); + assert!(list_sel.contains("endCursor"), "list selection missing endCursor: {list_sel}"); + assert!(list.parameters.contains_key("after"), "missing --after flag"); + + let create = issue.methods.get("create").unwrap(); + assert!(create.parameters.contains_key("title")); + assert!(create.parameters.contains_key("description")); + assert!(create.parameters.contains_key("team-id")); + + let gql = get.graphql.as_ref().unwrap(); + assert_eq!(gql.operation_type, "query"); + assert_eq!(gql.field_name, "issue"); + + let gql_create = create.graphql.as_ref().unwrap(); + assert_eq!(gql_create.operation_type, "mutation"); + assert_eq!(gql_create.field_name, "issueCreate"); + } + + #[test] + fn test_nested_input_flattening() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [{ + "name": "search", + "args": [{"name": "filter", "type": nn(input_obj("SearchFilter"))}], + "type": nn(obj("SearchConnection")), "isDeprecated": false + }] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [] + }, + { + "kind": "OBJECT", "name": "SearchConnection", + "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("SearchResult")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ] + }, + {"kind": "OBJECT", "name": "SearchResult", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "PageInfo", "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ]}, + { + "kind": "INPUT_OBJECT", "name": "SearchFilter", + "inputFields": [ + {"name": "query", "type": scalar("String")}, + {"name": "dateRange", "type": input_obj("DateRangeInput"), "description": "Bounded date range for filtering."}, + {"name": "minAmount", "type": scalar("Int")} + ] + }, + { + "kind": "INPUT_OBJECT", "name": "DateRangeInput", + "inputFields": [ + {"name": "start", "type": nn(scalar("String"))}, + {"name": "end", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let all_params: Vec = doc + .resources.values() + .flat_map(|r| r.methods.values()) + .flat_map(|m| m.parameters.keys().cloned()) + .collect(); + + assert!(all_params.iter().any(|k| k == "query"), "missing top-level query param: {all_params:?}"); + assert!(all_params.iter().any(|k| k.contains("start")), "missing dateRange.start: {all_params:?}"); + assert!(all_params.iter().any(|k| k.contains("end")), "missing dateRange.end: {all_params:?}"); + + // Parent object-level flag must ALSO be emitted for the nested input type. + // The flag key is "date-range" (no arg prefix, since flatten_input_type is called + // with an empty flag_prefix for top-level input args). + let search = doc.resources.get("search").expect("search resource missing"); + let list = search.methods.get("list").expect("search.list method missing"); + let date_range_flag = list.parameters.get("date-range") + .expect("parent 'date-range' object flag must be present"); + assert_eq!(date_range_flag.param_type.as_deref(), Some("object"), + "parent input-type flag must have param_type 'object'"); + assert!(!date_range_flag.required, + "parent object flag must be required: false at CLI level"); + assert_eq!(date_range_flag.graphql_input_arg.as_deref(), Some("filter"), + "parent object flag must have graphql_input_arg set"); + assert_eq!(date_range_flag.graphql_field_path.as_deref(), Some("dateRange"), + "parent object flag must have graphql_field_path set to the dotted path"); + // Description must flow through to the nested object-shorthand flag + // so --help shows the input field's description, not blank. + assert_eq!(date_range_flag.description.as_deref(), Some("Bounded date range for filtering."), + "nested object-shorthand flag must carry the input field's description"); + + // JFL-1.4: input-arg-level object-shorthand flag must also be emitted + // for the whole input arg `filter`. Lets users pass + // `--filter '{"query":"x","dateRange":{...}}'` as a single payload. + let filter_flag = list.parameters.get("filter") + .expect("input-arg-level 'filter' object flag must be present"); + assert_eq!(filter_flag.param_type.as_deref(), Some("object"), + "input-arg flag must have param_type 'object'"); + assert!(!filter_flag.required, + "input-arg flag must be required: false at CLI level"); + assert_eq!(filter_flag.graphql_input_arg.as_deref(), Some("filter"), + "input-arg flag must have graphql_input_arg set to the arg name"); + assert_eq!(filter_flag.graphql_field_path.as_deref(), Some(""), + "input-arg flag must have empty graphql_field_path"); + } + + #[test] + fn test_input_arg_object_shorthand_basic() { + // JFL-1.4: for `mutation foo(input: BarInput!)`, params must contain + // both `--input` (object) and per-field leaf flags from BarInput. + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", "fields": [] + }, + { + "kind": "OBJECT", "name": "Mutation", + "fields": [{ + "name": "foo", + "args": [{"name": "input", "type": nn(input_obj("BarInput"))}], + "type": nn(obj("BarPayload")), "isDeprecated": false + }] + }, + {"kind": "OBJECT", "name": "BarPayload", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + { + "kind": "INPUT_OBJECT", "name": "BarInput", + "inputFields": [ + {"name": "field", "type": nn(scalar("String"))} + ] + } + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let foo_method = doc.resources.get("foo") + .expect("foo resource missing") + .methods.get("execute") + .expect("foo.execute method missing"); + + let input_flag = foo_method.parameters.get("input") + .expect("--input arg-level object flag must be present"); + assert_eq!(input_flag.param_type.as_deref(), Some("object")); + assert_eq!(input_flag.graphql_input_arg.as_deref(), Some("input")); + assert_eq!(input_flag.graphql_field_path.as_deref(), Some("")); + assert!(!input_flag.required); + + let field_flag = foo_method.parameters.get("field") + .expect("--field per-field leaf flag must be present"); + assert_eq!(field_flag.param_type.as_deref(), Some("string")); + assert_eq!(field_flag.graphql_input_arg.as_deref(), Some("input")); + assert_eq!(field_flag.graphql_field_path.as_deref(), Some("field")); + } + + #[test] + fn test_undeclared_scalar_as_arg() { + let schema = make_schema(json!([ + { + "kind": "OBJECT", "name": "Query", + "fields": [{ + "name": "orders", + "args": [ + {"name": "after", "type": json!({"kind": "SCALAR", "name": "Cursor", "ofType": null})}, + {"name": "first", "type": scalar("Int")} + ], + "type": nn(obj("OrderConnection")), "isDeprecated": false + }] + }, + {"kind": "OBJECT", "name": "Mutation", "fields": []}, + {"kind": "OBJECT", "name": "OrderConnection", "fields": [ + {"name": "nodes", "args": [], "type": nn(list_of(nn(obj("Order")))), "isDeprecated": false}, + {"name": "pageInfo", "args": [], "type": nn(obj("PageInfo")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "Order", "fields": [ + {"name": "id", "args": [], "type": nn(scalar("ID")), "isDeprecated": false} + ]}, + {"kind": "OBJECT", "name": "PageInfo", "fields": [ + {"name": "hasNextPage", "args": [], "type": nn(scalar("Boolean")), "isDeprecated": false}, + {"name": "endCursor", "args": [], "type": scalar("String"), "isDeprecated": false} + ]} + ])); + + let doc = load_graphql_schema(&schema, "test", "https://api.example.com/graphql").unwrap(); + let all_params: Vec = doc + .resources.values() + .flat_map(|r| r.methods.values()) + .flat_map(|m| m.parameters.keys().cloned()) + .collect(); + assert!( + all_params.iter().any(|k| k == "after"), + "undeclared Cursor scalar should produce --after flag: {all_params:?}" + ); + } + +} diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..0e33982 --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,297 @@ +//! Path-addressed hook registries for the root [`CliApp`]. +//! +//! Hooks are registered against glob-style paths in the command tree +//! (e.g. `&["users", "**"]` fires for every operation under `users`). +//! The registry stores boxed async callbacks and matches them at +//! dispatch time. + +use serde_json::Value; + +use crate::binding::BoxFuture; +use crate::error::CliError; + +// ── Pattern matching ──────────────────────────────────────────────── + +/// A compiled path pattern. Segments are literal strings; `*` matches +/// one segment; `**` matches zero or more segments. +#[derive(Debug, Clone)] +pub struct PathPattern { + segments: Vec, +} + +#[derive(Debug, Clone)] +enum PatternSegment { + Literal(String), + Single, // * + Globstar, // ** +} + +impl PathPattern { + pub fn new(segments: &[&str]) -> Self { + Self { + segments: segments + .iter() + .map(|s| match *s { + "**" => PatternSegment::Globstar, + "*" => PatternSegment::Single, + lit => PatternSegment::Literal(lit.to_string()), + }) + .collect(), + } + } + + /// Returns `true` if `path` matches this pattern. + pub fn matches(&self, path: &[String]) -> bool { + Self::do_match(&self.segments, path) + } + + fn do_match(pattern: &[PatternSegment], path: &[String]) -> bool { + match (pattern.first(), path.first()) { + (None, None) => true, + (None, Some(_)) => false, + (Some(PatternSegment::Globstar), _) => { + // ** can match zero segments (skip globstar) or one + // segment (consume one path element, keep globstar). + Self::do_match(&pattern[1..], path) + || (!path.is_empty() && Self::do_match(pattern, &path[1..])) + } + (Some(_), None) => { + // Remaining pattern segments with no path left — only + // matches if all remaining are globstars. + pattern.iter().all(|s| matches!(s, PatternSegment::Globstar)) + } + (Some(PatternSegment::Literal(lit)), Some(seg)) => { + lit == seg && Self::do_match(&pattern[1..], &path[1..]) + } + (Some(PatternSegment::Single), Some(_)) => { + Self::do_match(&pattern[1..], &path[1..]) + } + } + } +} + +// ── Hook storage ──────────────────────────────────────────────────── + +/// A `transform_response` callback: `(Value, op_path) -> Result`. +pub type TransformResponseFn = + Box) -> BoxFuture<'static, Result> + Send + Sync>; + +/// A `recover_error` callback: `(CliError, op_path) -> Result>`. +/// Returning `Ok(Some(v))` short-circuits with `v` as the response; +/// `Ok(None)` lets the error propagate to the next hook or default path. +pub type RecoverErrorFn = Box< + dyn Fn(CliError, Vec) -> BoxFuture<'static, Result, CliError>> + + Send + + Sync, +>; + +/// A path-addressed hook entry. +pub(crate) struct HookEntry { + pattern: PathPattern, + callback: F, +} + +/// Registry of spec-level hooks registered on the root `CliApp`. +#[derive(Default)] +pub struct HookRegistry { + transform_response: Vec>, + recover_error: Vec>, +} + +impl HookRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn add_transform_response(&mut self, path: &[&str], f: TransformResponseFn) { + self.transform_response.push(HookEntry { + pattern: PathPattern::new(path), + callback: f, + }); + } + + pub fn add_recover_error(&mut self, path: &[&str], f: RecoverErrorFn) { + self.recover_error.push(HookEntry { + pattern: PathPattern::new(path), + callback: f, + }); + } + + /// Run matching `transform_response` hooks in registration order. + pub async fn run_transform_response( + &self, + mut value: Value, + op_path: &[String], + ) -> Result { + for entry in &self.transform_response { + if entry.pattern.matches(op_path) { + value = (entry.callback)(value, op_path.to_vec()).await?; + } + } + Ok(value) + } + + /// Run matching `recover_error` hooks in registration order. + /// First `Ok(Some(v))` wins; `Ok(None)` defers to the next hook. + /// + /// The original error is duplicated before being passed to each + /// hook, so declining hooks (`Ok(None)`) do not destroy the error + /// for subsequent hooks or the final error path. + pub async fn run_recover_error( + &self, + err: CliError, + op_path: &[String], + ) -> Result { + let mut current_err = err; + for entry in &self.recover_error { + if entry.pattern.matches(op_path) { + // Duplicate before passing to the callback so the + // original is preserved if the hook declines. + let err_for_hook = current_err.duplicate(); + match (entry.callback)(err_for_hook, op_path.to_vec()).await { + Ok(Some(value)) => return Ok(value), + Ok(None) => { + // Hook declined — original error preserved + // via duplicate() above; current_err unchanged. + } + Err(new_err) => { + current_err = new_err; + } + } + } + } + Err(current_err) + } + + pub fn is_empty(&self) -> bool { + self.transform_response.is_empty() && self.recover_error.is_empty() + } + + /// Returns `true` if at least one `recover_error` hook is registered. + pub fn has_recover_error(&self) -> bool { + !self.recover_error.is_empty() + } + + /// Validate that every registered hook pattern matches at least one + /// leaf command in the given command tree. Returns an error listing + /// all unmatched patterns. + pub fn validate_patterns(&self, cmd: &clap::Command) -> Result<(), crate::error::CliError> { + if self.is_empty() { + return Ok(()); + } + let leaves = collect_leaf_paths(cmd, &mut Vec::new()); + let mut unmatched = Vec::new(); + for entry in &self.transform_response { + if !leaves.iter().any(|leaf| entry.pattern.matches(leaf)) { + unmatched.push(format!( + "transform_response pattern {:?} matches no operations", + pattern_to_strings(&entry.pattern), + )); + } + } + for entry in &self.recover_error { + if !leaves.iter().any(|leaf| entry.pattern.matches(leaf)) { + unmatched.push(format!( + "recover_error pattern {:?} matches no operations", + pattern_to_strings(&entry.pattern), + )); + } + } + if unmatched.is_empty() { + Ok(()) + } else { + Err(crate::error::CliError::Validation(unmatched.join("; "))) + } + } +} + +/// Recursively collect all leaf command paths (commands with no +/// subcommands). Includes hidden commands so that `.hide()` followed by +/// a hook on the hidden path does not produce a false validation error. +fn collect_leaf_paths(cmd: &clap::Command, prefix: &mut Vec) -> Vec> { + let subs: Vec<_> = cmd.get_subcommands().collect(); + if subs.is_empty() { + return vec![prefix.clone()]; + } + let mut leaves = Vec::new(); + for sub in subs { + let name = sub.get_name().to_string(); + // Skip built-in utility commands and binding-internal + // subcommands that bypass the hook pipeline. + if name == "help" || name == "completion" || name == "man" + || name == "generate-skills" + { + continue; + } + prefix.push(name); + leaves.extend(collect_leaf_paths(sub, prefix)); + prefix.pop(); + } + leaves +} + +/// Extract display-friendly strings from a pattern for error messages. +fn pattern_to_strings(pattern: &PathPattern) -> Vec { + pattern.segments.iter().map(|s| match s { + PatternSegment::Literal(lit) => lit.clone(), + PatternSegment::Single => "*".to_string(), + PatternSegment::Globstar => "**".to_string(), + }).collect() +} + +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pattern_exact_match() { + let p = PathPattern::new(&["users", "get"]); + assert!(p.matches(&["users".into(), "get".into()])); + assert!(!p.matches(&["users".into()])); + assert!(!p.matches(&["users".into(), "get".into(), "extra".into()])); + } + + #[test] + fn pattern_single_wildcard() { + let p = PathPattern::new(&["users", "*"]); + assert!(p.matches(&["users".into(), "get".into()])); + assert!(p.matches(&["users".into(), "list".into()])); + assert!(!p.matches(&["users".into()])); + assert!(!p.matches(&["users".into(), "get".into(), "extra".into()])); + } + + #[test] + fn pattern_globstar() { + let p = PathPattern::new(&["**"]); + assert!(p.matches(&[])); + assert!(p.matches(&["users".into()])); + assert!(p.matches(&["users".into(), "get".into()])); + } + + #[test] + fn pattern_globstar_prefix() { + let p = PathPattern::new(&["users", "**"]); + assert!(p.matches(&["users".into()])); + assert!(p.matches(&["users".into(), "get".into()])); + assert!(p.matches(&["users".into(), "a".into(), "b".into()])); + assert!(!p.matches(&["posts".into()])); + } + + #[test] + fn pattern_globstar_suffix() { + let p = PathPattern::new(&["**", "list"]); + assert!(p.matches(&["list".into()])); + assert!(p.matches(&["users".into(), "list".into()])); + assert!(p.matches(&["a".into(), "b".into(), "list".into()])); + assert!(!p.matches(&["users".into(), "get".into()])); + } + + #[test] + fn pattern_empty() { + let p = PathPattern::new(&[]); + assert!(p.matches(&[])); + assert!(!p.matches(&["a".into()])); + } +} diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..484fc8e --- /dev/null +++ b/src/http.rs @@ -0,0 +1,1901 @@ +//! HTTP client construction and TLS-error diagnostics. +//! +//! [`HttpConfig`] holds the inputs that go into building a [`reqwest::Client`] +//! for a CLI: the binary name (used to scope env vars and to compose the +//! `User-Agent`) and any compile-time trust roots a binary author baked in +//! via `CliApp::extra_root_cert`. +//! +//! [`HttpConfig::build_client`] honors a small set of environment variables +//! so users can adapt TLS / proxy behavior without rebuilding the CLI. +//! Variables are prefixed with `_` (the CLI's name uppercased with `-` +//! mapped to `_`). +//! +//! | Variable | Effect | +//! | --------------------------------- | --------------------------------------------------- | +//! | `_CA_BUNDLE` | Path to PEM file appended to the default trust roots. Generic fallback: `SSL_CERT_FILE`. | +//! | `_INSECURE` = `1`/`true`/`yes` | Disable TLS verification (with a one-time stderr warning). | +//! | `_PROXY` | HTTP(S) proxy URL — replaces `HTTPS_PROXY`/`HTTP_PROXY` for this CLI. Pair with `_NO_PROXY` for a scoped bypass list, or rely on the global `NO_PROXY` (used as a fallback when `_NO_PROXY` is unset). | +//! | `_TIMEOUT_SECS` | Total request timeout in seconds (default: no timeout). | +//! | `_CONNECT_TIMEOUT_SECS` | Connection-establishment timeout in seconds. | +//! | `_USER_AGENT_SUFFIX` | Product token appended to the `User-Agent` (e.g. `partner-app/3.1`) so a tool built on top of the CLI can tag its traffic without replacing the CLI's identity. | +//! +//! Aliases: `_EXTRA_CA_CERTS` (= `_CA_BUNDLE`), +//! `_INSECURE_SKIP_VERIFY` (= `_INSECURE`). +//! +//! `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` are honored by reqwest's defaults +//! when the scoped overrides are absent. +//! +//! ## Configuration timing +//! +//! Compile-time roots passed via [`HttpConfig::with_extra_root_cert`] are +//! captured once when the config is built. Env vars are re-read on every +//! [`HttpConfig::build_client`] call, so a long-running consumer that +//! rebuilds the client picks up env changes for `_INSECURE`, `_PROXY`, etc. +//! For one-shot CLI use this distinction doesn't matter. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::Duration; + +use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT}; + +use crate::error::CliError; + +// ---------------------------------------------------------------------------- +// HttpConfig — the SDK's HTTP layer configuration +// ---------------------------------------------------------------------------- + +/// Configuration for building HTTP clients on behalf of a named CLI. +/// +/// Holds the binary name (which scopes env-var lookups) and any compile-time +/// trust roots the binary author registered. `CliApp::run` builds one once +/// and threads it through to the executor. +#[derive(Clone, Debug)] +pub struct HttpConfig { + /// CLI binary name (e.g. `"bigcommerce"`). Cheap to clone via `Arc`. + name: Arc, + /// Env-var prefix derived once from `name`: uppercase + `-` → `_`. Cached + /// so the transform isn't recomputed on every `build_client` call (and + /// so external callers can't forget the `-` substitution). + prefix: Arc, + /// Trust roots baked in at compile time. We store parsed `Certificate`s + /// (not raw PEM bytes) so `build_client` doesn't re-parse — and so a + /// later `build_client` failure can only come from runtime input. + extra_root_certs: Vec, + /// Raw PEM bytes for each compile-time trust root, kept alongside the + /// parsed `reqwest::Certificate` above. Required by transport-neutral + /// consumers (`resolve()`) that build their own TLS connectors (e.g. + /// `tokio-tungstenite` for WebSockets) and need to feed PEM in rather + /// than reqwest-typed certs. Each `Vec` is the raw bytes supplied + /// to [`HttpConfig::with_extra_root_cert`]. + extra_root_certs_pem: Vec>, + /// Consumer-supplied `User-Agent` suffix resolved from the + /// `--user-agent-suffix` flag. Takes precedence over the + /// `_USER_AGENT_SUFFIX` env var when set. `None` means fall back + /// to the env var (or no suffix). + user_agent_suffix_override: Option>, +} + +/// Transport-neutral view of the resolved HTTP/TLS configuration. +/// +/// Returned by [`HttpConfig::resolve`]. Holds compile-time roots, the +/// env-var-resolved CA bundle (if any), insecure-skip-verify flag, proxy +/// settings, and timeouts. Lets non-reqwest transports (e.g. WebSocket via +/// `tokio-tungstenite`, future SSE / gRPC) build their own clients while +/// honouring the same `_*` env vars users already configure for the +/// reqwest path. +/// +/// The reqwest path in [`HttpConfig::build_client`] reads env vars +/// independently for historical reasons — keep both readers in sync. The +/// `resolved_matches_build_client` test asserts agreement on the subset +/// that's representable in both shapes. +#[derive(Debug, Clone)] +pub struct ResolvedTlsConfig { + /// Raw PEM bytes of all trust roots — compile-time first, then the + /// env-resolved bundle (if any). Order matches the order they would be + /// added to a `reqwest::ClientBuilder` via `add_root_certificate`. + pub extra_root_certs_pem: Vec>, + /// `_INSECURE=1` / `_INSECURE_SKIP_VERIFY=1` was set. + /// Transports honoring this should disable cert+hostname verification. + pub insecure_skip_verify: bool, + /// `_PROXY=` was set. Transports that support HTTP proxying + /// should route through this URL. The `no_proxy` field carries either + /// `_NO_PROXY` or the fallback `NO_PROXY`, matching the reqwest + /// path's bypass-list resolution. + pub proxy: Option, + /// `_CONNECT_TIMEOUT_SECS` if set. Bound on socket establishment. + pub connect_timeout: Option, + /// `_TIMEOUT_SECS` if set. Bound on total request lifetime + /// (reqwest semantics); for streaming transports (WebSocket), use as a + /// handshake-only deadline since the connection lifetime is unbounded. + pub request_timeout: Option, +} + +/// Resolved proxy override, as parsed from `_PROXY` / `_NO_PROXY`. +#[derive(Debug, Clone)] +pub struct ResolvedProxy { + /// Proxy URL (`http://...` or `https://...`). + pub url: String, + /// Bypass list — either `_NO_PROXY` (if set) or the fallback + /// `NO_PROXY` env var. `None` means honor the standard reqwest defaults. + pub no_proxy: Option, +} + +impl HttpConfig { + /// Create a config for the given CLI name. Empty names are rejected — + /// they would silently disable the entire env-var scoping system. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(CliError::Other(anyhow::anyhow!( + "HttpConfig::new called with empty name — \ + env-var scoping requires a non-empty CLI name" + ))); + } + let prefix: Arc = Arc::from(name.to_uppercase().replace('-', "_")); + Ok(Self { + name: Arc::from(name), + prefix, + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + user_agent_suffix_override: None, + }) + } + + /// Append a PEM-encoded trust root that this CLI will accept on top of + /// the system's default roots. Typically called via `CliApp::extra_root_cert`. + /// Returns an error if the PEM is unparseable or contains zero certs. + pub fn with_extra_root_cert(mut self, pem: &[u8]) -> Result { + // Validate the PEM up front (`parse_extra_root_cert` rejects empty / + // unparseable bundles). Storing the raw bytes alongside lets + // non-reqwest transports build their own connectors without + // re-parsing through reqwest types. + self.extra_root_certs.extend(parse_extra_root_cert(pem)?); + self.extra_root_certs_pem.push(pem.to_vec()); + Ok(self) + } + + /// Append already-parsed trust roots. Used internally by `CliApp` to + /// thread compile-time roots from the builder into the runtime config + /// without re-parsing. The matching PEM bytes must be supplied so + /// `resolve()` can hand them to non-reqwest transports. + pub(crate) fn with_parsed_root_certs( + mut self, + certs: impl IntoIterator, + pem_bytes: impl IntoIterator>, + ) -> Self { + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.extend(pem_bytes); + self + } + + /// Set the consumer-supplied `User-Agent` suffix (from the + /// `--user-agent-suffix` flag). When present it takes precedence over + /// the `_USER_AGENT_SUFFIX` env var. A blank *or* header-invalid + /// value clears the override so the env-var fallback still applies — + /// an unusable flag value never suppresses an otherwise-valid env + /// suffix (and never drops the CLI's own `User-Agent`). + pub fn with_user_agent_suffix_override(mut self, suffix: Option) -> Self { + self.user_agent_suffix_override = suffix + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && HeaderValue::from_str(s).is_ok()) + .map(Arc::from); + self + } + + /// CLI binary name (e.g. `"bigcommerce"`). + pub fn name(&self) -> &str { + &self.name + } + + /// Env-var prefix derived from the binary name (uppercase, `-` → `_`). + /// `BIGCOMMERCE`, `BOX`, etc. Use this when constructing scoped env vars + /// so the transform stays consistent across the codebase. + pub fn env_prefix(&self) -> &str { + &self.prefix + } + + /// Compose the `User-Agent` header value this CLI's HTTP client sends: + /// `{product}/{version}` (e.g. `elevenlabs-cli/1.4.0`), optionally followed + /// by a consumer-supplied suffix (e.g. `elevenlabs-cli/1.4.0 partner-app/3.1`). + /// + /// The product token is derived from the binary name so each generated + /// CLI's traffic is distinguishable on the API backend (rather than the + /// shared `fern-cli-sdk` crate). It is normalized to end with `-cli` (see + /// [`HttpConfig::user_agent_product`]) so the token unambiguously denotes + /// CLI traffic. The version is the crate version stamped in at generation + /// time (`CARGO_PKG_VERSION`), which for a generated CLI is the release + /// version from `fern generate`. + /// + /// A tool built on top of this CLI can append its own product token + /// either with the `--user-agent-suffix` flag or by setting + /// `_USER_AGENT_SUFFIX` (see [`HttpConfig::user_agent_suffix`]); the + /// suffix is added after the CLI's own identity rather than replacing it, + /// so both are visible to the backend. + pub fn user_agent(&self) -> String { + let base = format!( + "{}/{}", + Self::user_agent_product(&self.name), + env!("CARGO_PKG_VERSION") + ); + match self.user_agent_suffix() { + Some(suffix) => format!("{base} {suffix}"), + None => base, + } + } + + /// Derive the `User-Agent` product token from the binary name, ensuring it + /// ends with `-cli` so the token clearly identifies CLI traffic. A binary + /// named `elevenlabs` yields `elevenlabs-cli`; one already named + /// `elevenlabs-cli` is left unchanged (the suffix is not doubled). + fn user_agent_product(name: &str) -> String { + if name.ends_with("-cli") { + name.to_string() + } else { + format!("{name}-cli") + } + } + + /// Read the optional consumer-supplied `User-Agent` suffix. The + /// configured suffix flag (surfaced via + /// [`HttpConfig::with_user_agent_suffix_override`]) takes precedence; if + /// unset, falls back to the scoped env var. Both the flag and the env var + /// default to `--user-agent-suffix` / `_USER_AGENT_SUFFIX` but can + /// be renamed at generation time (see [`crate::user_agent`]). This lets a + /// tool built on top of the CLI voluntarily tag its traffic (e.g. + /// `partner-app/3.1`) without replacing the CLI's own identity. + /// + /// The value is trimmed and only accepted if it is non-empty and valid + /// HTTP header content; otherwise it is ignored so a malformed suffix can + /// never drop the CLI's own `User-Agent`. + fn user_agent_suffix(&self) -> Option { + let raw = match &self.user_agent_suffix_override { + Some(s) => Some(s.to_string()), + None => first_env([scoped(&self.prefix, &crate::user_agent::suffix_env_segment())]), + }; + raw.map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty() && HeaderValue::from_str(s).is_ok()) + } + + /// Resolve the transport-neutral view of this config: compile-time + /// trust roots concatenated with the env-resolved `_CA_BUNDLE`, + /// the `_INSECURE` flag, the proxy override, and timeouts. + /// + /// Used by non-reqwest transports (`fern_cli_sdk::websocket`, future + /// SSE / raw-socket consumers) that need to build their own TLS + /// connector while honoring the same `_*` env vars users + /// already configure for the reqwest path. + /// + /// Reads env vars at call time. Reading the CA bundle file can fail + /// (missing / unparseable / no PEM certs) — those errors surface here + /// rather than getting swallowed by the transport's own connect path. + /// + /// Side effects mirror [`HttpConfig::build_client`]: emits the + /// `_INSECURE` warning at most once per (binary, process). No + /// network calls. + pub fn resolve(&self) -> Result { + let prefix = &self.prefix; + + let mut extra_root_certs_pem: Vec> = self.extra_root_certs_pem.clone(); + if let Some(path) = first_env([ + scoped(prefix, "_CA_BUNDLE"), + scoped(prefix, "_EXTRA_CA_CERTS"), + "SSL_CERT_FILE".to_string(), + ]) { + let pem = std::fs::read(&path).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to read CA bundle from {path}: {e}" + )) + })?; + // Validate the bundle here so transport callers don't have to + // re-implement the "empty / non-PEM" diagnostic. We parse but + // discard the certs — the raw bytes are what we hand back. + let source = format!("CA bundle at {path}"); + let _ = parse_pem_bundle(&pem, &source)?; + extra_root_certs_pem.push(pem); + } + + let insecure_skip_verify = if let Some(active_key) = first_env_truthy([ + scoped(prefix, "_INSECURE"), + scoped(prefix, "_INSECURE_SKIP_VERIFY"), + ]) { + warn_insecure_once(&self.name, &active_key); + true + } else { + false + }; + + let proxy = first_env([scoped(prefix, "_PROXY")]).map(|url| { + // Mirror the reqwest path's bypass-list resolution: _NO_PROXY + // wins when set, otherwise fall back to the standard NO_PROXY env. + let no_proxy = first_env([scoped(prefix, "_NO_PROXY")]) + .or_else(|| first_env(["NO_PROXY".to_string()])); + ResolvedProxy { url, no_proxy } + }); + + let connect_timeout = parse_secs(&scoped(prefix, "_CONNECT_TIMEOUT_SECS")) + .map(Duration::from_secs); + let request_timeout = parse_secs(&scoped(prefix, "_TIMEOUT_SECS")) + .map(Duration::from_secs); + + Ok(ResolvedTlsConfig { + extra_root_certs_pem, + insecure_skip_verify, + proxy, + connect_timeout, + request_timeout, + }) + } + + /// Build an HTTP client, applying compile-time roots, env-var overrides, + /// proxy settings, and timeouts. Reads `_*` env vars at call time; + /// compile-time roots were captured when this config was built. + pub fn build_client(&self) -> Result { + let prefix = &self.prefix; + + let mut builder = reqwest::Client::builder(); + let user_agent = self.user_agent(); + if let Ok(header_value) = HeaderValue::from_str(&user_agent) { + let mut headers = HeaderMap::new(); + headers.insert(USER_AGENT, header_value); + builder = builder.default_headers(headers); + } + + // --- Compile-time trust roots (from CliApp::extra_root_cert) --- + for cert in &self.extra_root_certs { + builder = builder.add_root_certificate(cert.clone()); + } + + // --- Runtime trust roots from env --- + if let Some(path) = first_env([ + scoped(prefix, "_CA_BUNDLE"), + scoped(prefix, "_EXTRA_CA_CERTS"), + "SSL_CERT_FILE".to_string(), + ]) { + let pem = std::fs::read(&path).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to read CA bundle from {path}: {e}" + )) + })?; + let source = format!("CA bundle at {path}"); + for cert in parse_pem_bundle(&pem, &source)? { + builder = builder.add_root_certificate(cert); + } + } + + // --- Insecure mode (opt-in, loud) --- + if let Some(active_key) = first_env_truthy([ + scoped(prefix, "_INSECURE"), + scoped(prefix, "_INSECURE_SKIP_VERIFY"), + ]) { + warn_insecure_once(&self.name, &active_key); + builder = builder + .danger_accept_invalid_certs(true) + .danger_accept_invalid_hostnames(true); + } + + // --- Redirect policy --- + // + // reqwest's default follows up to `MAX_REDIRECTS` hops and strips only + // the headers it hardcodes as sensitive: `Authorization`, `Cookie`, + // `Proxy-Authorization`, `WWW-Authenticate`. A Fern CLI's credential + // very often lives in a spec-declared *custom* header (`xi-api-key`, + // `X-API-Key`), which reqwest cannot know about — so a redirect to an + // attacker-controlled host resends it verbatim. `HeaderValue:: + // set_sensitive` does not help: reqwest's stripping consults its own + // list, not that flag. + // + // Even for `Authorization`-based auth, where reqwest does strip, the + // request URL and body are still delivered to the new host. So refuse + // to cross a host boundary at all rather than trying to sanitize. + // + // Refusing is deliberately loud. Silently dropping the credential + // would turn a redirect into a confusing 401 that looks like the + // user's credentials are wrong. + let allow_cross_host_key = scoped(prefix, "_ALLOW_CROSS_HOST_REDIRECTS"); + if first_env_truthy([allow_cross_host_key.clone()]).is_none() { + let env_key = allow_cross_host_key.clone(); + builder = builder.redirect(reqwest::redirect::Policy::custom(move |attempt| { + // `Policy::custom` replaces reqwest's built-in hop limit, so + // re-impose it here or a redirect loop runs forever. + if attempt.previous().len() >= MAX_REDIRECTS { + return attempt + .error(format!("too many redirects (limit {MAX_REDIRECTS})")); + } + // Render both origins before deciding: `Attempt::error` + // consumes `attempt`, so nothing may still borrow it. + let crossing = attempt + .previous() + .last() + .map(|previous| { + ( + crosses_host(previous, attempt.url()), + origin_only(previous), + origin_only(attempt.url()), + ) + }) + .filter(|(is_crossing, _, _)| *is_crossing); + match crossing { + Some((_, from, to)) => attempt.error(format!( + "refusing to follow a redirect from {from} to {to}: it crosses a host \ + boundary, and a credential carried in a custom header would be resent \ + to the new host. If this redirect is expected, set {env_key}=1 to \ + allow it." + )), + None => attempt.follow(), + } + })); + } + + // --- Proxy override --- + // + // Reqwest's default behavior reads `HTTPS_PROXY` / `HTTP_PROXY` and + // adds them automatically. Adding our explicit `.proxy(...)` on top + // would result in *both* being tried in order — the env-detected one + // first. So when `_PROXY` is set, we clear reqwest's + // auto-detection with `.no_proxy()` first, then add ours. + // + // Bypass-list semantics: `_PROXY` *replaces* the global + // `HTTPS_PROXY`/`HTTP_PROXY`, but the bypass list is *augmenting*: + // - if `_NO_PROXY` is set, it's used (global NO_PROXY ignored); + // - otherwise, the standard `NO_PROXY` is honored as a fallback so + // a user who only set the shell-wide bypass list doesn't lose it. + // Standalone `_NO_PROXY` (without `_PROXY`) is *not* + // honored — it would have ambiguous semantics (override which proxy?). + let proxy_key = scoped(prefix, "_PROXY"); + if let Some(url) = first_env([proxy_key.clone()]) { + let mut proxy = reqwest::Proxy::all(&url).map_err(|e| { + CliError::Other(anyhow::anyhow!("invalid {proxy_key}={url}: {e}")) + })?; + if let Some(list) = first_env([scoped(prefix, "_NO_PROXY")]) { + if let Some(np) = reqwest::NoProxy::from_string(&list) { + proxy = proxy.no_proxy(Some(np)); + } + } else if let Some(np) = reqwest::NoProxy::from_env() { + proxy = proxy.no_proxy(Some(np)); + } + builder = builder.no_proxy().proxy(proxy); + } + + // --- Timeouts --- + if let Some(secs) = parse_secs(&scoped(prefix, "_TIMEOUT_SECS")) { + builder = builder.timeout(std::time::Duration::from_secs(secs)); + } + if let Some(secs) = parse_secs(&scoped(prefix, "_CONNECT_TIMEOUT_SECS")) { + builder = builder.connect_timeout(std::time::Duration::from_secs(secs)); + } + + builder.build().map_err(|e| { + CliError::Other(anyhow::anyhow!("failed to build HTTP client: {e}")) + }) + } +} + +/// Parse a PEM bundle into trust-root certs, with the SDK's standard +/// validation: empty bytes / no PEM headers / unparseable bytes all surface +/// as errors. `source` is woven into error messages so users can tell where +/// the bad PEM came from (`"extra root cert"`, `"CA bundle at /path/..."`). +fn parse_pem_bundle(pem: &[u8], source: &str) -> Result, CliError> { + let certs = reqwest::Certificate::from_pem_bundle(pem).map_err(|e| { + CliError::Other(anyhow::anyhow!( + "failed to parse {source}: {e} — check the bytes are valid PEM-encoded certificates" + )) + })?; + if certs.is_empty() { + return Err(CliError::Other(anyhow::anyhow!( + "{source} contains no PEM certificates — check the bytes are PEM-encoded" + ))); + } + Ok(certs) +} + +/// Convenience wrapper for the compile-time path. Used by +/// [`HttpConfig::with_extra_root_cert`] and `CliApp::extra_root_cert`. +pub(crate) fn parse_extra_root_cert(pem: &[u8]) -> Result, CliError> { + parse_pem_bundle(pem, "extra root cert") +} + +// ---------------------------------------------------------------------------- +// TLS error diagnostics +// ---------------------------------------------------------------------------- + +/// If the given reqwest error looks like a TLS chain failure, print a hint +/// to stderr telling the user how to fix it (export `_CA_BUNDLE`, +/// unset `HTTPS_PROXY`, or use `_INSECURE=1` for debugging). +/// +/// Emits at most once per (binary, process) so paginated callers don't spam. +pub(crate) fn maybe_emit_tls_hint(cfg: &HttpConfig, err: &reqwest::Error) { + if !looks_like_tls_failure(err) { + return; + } + if !is_first_emission(&cfg.name, "tls") { + return; + } + let prefix = cfg.env_prefix(); + eprintln!( + "hint: TLS chain validation failed. If you're behind a corporate proxy or \ + interception tool (Proxyman, Charles, mitmproxy):\n \ + export {prefix}_CA_BUNDLE=/path/to/ca.pem # trust an extra root\n \ + export SSL_CERT_FILE=/path/to/ca.pem # generic fallback\n \ + {prefix}_INSECURE=1 # skip verification (debugging only)" + ); +} + +/// Detect whether a reqwest error is plausibly a TLS chain failure. Uses the +/// typed `is_connect()` predicate plus a deliberately-broad substring match +/// against `"certificate"` in the rendered error chain. We accept some false +/// positives (the hint is benign when wrong) in exchange for not missing real +/// TLS failures when reqwest's error wording shifts between versions. +fn looks_like_tls_failure(err: &reqwest::Error) -> bool { + if !err.is_connect() { + return false; + } + // `{:#}` prints the full source chain — TLS errors are usually wrapped + // several layers deep, with the actual word appearing near the bottom. + format!("{err:#}").to_lowercase().contains("certificate") +} + +/// Print the insecure-mode warning at most once per (binary, process). +fn warn_insecure_once(name: &str, active_key: &str) { + if !is_first_emission(name, "insecure") { + return; + } + eprintln!( + "warning: TLS verification disabled via {active_key} — \ + requests are vulnerable to MITM. Unset for production use." + ); +} + +/// Returns true the *first* time a (binary, kind) pair is seen in this +/// process, false thereafter. Lets us print one-shot warnings/hints without +/// silencing them across multiple binaries built on the SDK in the same +/// process (e.g. test harnesses, library consumers wiring up two CLIs). +fn is_first_emission(name: &str, kind: &str) -> bool { + static EMITTED: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + let mut guard = EMITTED.lock().unwrap_or_else(|e| e.into_inner()); + guard.insert(format!("{name}::{kind}")) +} + +// ---------------------------------------------------------------------------- +// Env-var helpers +// ---------------------------------------------------------------------------- + +/// Format a scoped env-var name. `scoped("BIGCOMMERCE", "_CA_BUNDLE")` → +/// `"BIGCOMMERCE_CA_BUNDLE"`. +fn scoped(prefix: &str, suffix: &str) -> String { + format!("{prefix}{suffix}") +} + +/// Return the first non-empty env var value among the given keys, in order. +/// Redirect hops allowed before the chain is treated as a loop. Matches +/// reqwest's own default, which `Policy::custom` replaces. +const MAX_REDIRECTS: usize = 10; + +/// Whether following `next` leaves the host `previous` was served from. +/// +/// Compares the host only, deliberately more permissive than reqwest's own +/// cross-origin test (which also compares the effective port). A plain +/// `http://api.example.com` → `https://api.example.com` upgrade, or a hop to a +/// different port on the same host, stays within one operator's +/// infrastructure; the threat this guards is a hop to a *different host*. +/// reqwest still applies its stricter port-sensitive stripping to +/// `Authorization` on top of this, so nothing is weakened by the looser rule. +fn crosses_host(previous: &reqwest::Url, next: &reqwest::Url) -> bool { + previous.host_str() != next.host_str() +} + +/// `scheme://host[:port]` with the path, query and fragment dropped. Redirect +/// targets are frequently pre-signed URLs whose query string carries a +/// credential, so error messages name the origin only. +fn origin_only(url: &reqwest::Url) -> String { + match (url.host_str(), url.port()) { + (Some(host), Some(port)) => format!("{}://{host}:{port}", url.scheme()), + (Some(host), None) => format!("{}://{host}", url.scheme()), + (None, _) => url.scheme().to_string(), + } +} + +/// Convert a redirect-policy failure into a client-side error, or return the +/// original error untouched. +/// +/// A refused cross-host redirect (and a hop-limit overrun) reaches the executor +/// as an ordinary [`reqwest::Error`], so without this it lands in the generic +/// transport arm and gets two things wrong. It is retried — `decide_retry` sees +/// a transport failure and re-issues a request whose outcome cannot change — +/// and it is reported as `internalError` with a 500, which reads as "the CLI +/// broke" when the CLI in fact did its job. Worse, the explanation ends up only +/// in the structured output; stderr shows a bare "HTTP request failed", so the +/// two hosts and the opt-out variable never reach anyone reading a log. +/// +/// Classified as [`CliError::Validation`] — the same bucket as other +/// refused-before-sending conditions. It is a policy decision rather than +/// malformed input, but for the person at the terminal the useful distinction is +/// "is this mine to act on?", and it is: the message names the opt-out. +pub(crate) fn redirect_refusal_error(error: &reqwest::Error) -> Option { + if !error.is_redirect() { + return None; + } + // Walk the source chain: reqwest's own Display is just "error following + // redirect for url (...)"; the reason lives in the policy's error. + let mut message = error.to_string(); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + message.push_str(": "); + message.push_str(&cause.to_string()); + source = std::error::Error::source(cause); + } + Some(CliError::Validation(message)) +} + +/// Refuse a pagination target that crosses a host boundary. +/// +/// The same trust boundary the redirect policy above guards, reached by a +/// different route: `x-fern-pagination`'s `Uri` variant takes a +/// server-supplied next-page URL and the `Path` variant resolves one that may +/// be absolute, so in both cases the *response* chooses where the next request +/// goes. Following that to another host resends the credential — including a +/// spec-declared custom header (`xi-api-key`, `X-API-Key`) that reqwest cannot +/// know to strip — so the same rule applies: refuse to leave the host. +/// +/// Host-only comparison, matching [`crosses_host`]: an `http` -> `https` +/// upgrade or a port change stays within one operator's infrastructure. A +/// `next` that is relative (or otherwise not an absolute URL) cannot redirect +/// anywhere, so it is allowed without inspection. +/// +/// Opt out with `_ALLOW_CROSS_HOST_PAGINATION`, deliberately separate +/// from the redirect opt-out: allowing a redirect is not consent to let a +/// response body steer the next request. +pub(crate) fn check_pagination_target( + cli_name: &str, + base_url: &str, + next_url: &str, +) -> Result<(), String> { + let Ok(next) = reqwest::Url::parse(next_url) else { + // Relative — inherits the base's origin, so there is nothing to cross. + return Ok(()); + }; + let base = reqwest::Url::parse(base_url) + .map_err(|e| format!("base URL `{base_url}` is not a valid URL: {e}"))?; + if !crosses_host(&base, &next) { + return Ok(()); + } + let prefix = cli_name.to_uppercase().replace('-', "_"); + let allow_key = scoped(&prefix, "_ALLOW_CROSS_HOST_PAGINATION"); + if first_env_truthy([allow_key.clone()]).is_some() { + return Ok(()); + } + Err(format!( + "refusing to follow a pagination link from {} to {}: it crosses a host boundary, and a \ + credential carried in a custom header would be sent to the new host. If this is \ + expected, set {allow_key}=1 to allow it.", + origin_only(&base), + origin_only(&next), + )) +} + +fn first_env>(keys: impl IntoIterator) -> Option { + keys.into_iter().find_map(|k| { + let k = k.as_ref(); + if k.is_empty() { + return None; + } + std::env::var(k).ok().filter(|v| !v.is_empty()) + }) +} + +/// Like `first_env`, but checks for truthy values and returns the *name* of +/// the env var that fired so warnings can name the actual variable the user +/// set. +fn first_env_truthy>(keys: impl IntoIterator) -> Option { + keys.into_iter().find_map(|k| { + let k = k.as_ref(); + if k.is_empty() { + return None; + } + match std::env::var(k) { + Ok(v) if is_truthy(&v) => Some(k.to_string()), + _ => None, + } + }) +} + +fn is_truthy(v: &str) -> bool { + v.eq_ignore_ascii_case("1") + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + || v.eq_ignore_ascii_case("on") +} + +fn parse_secs(key: &str) -> Option { + std::env::var(key).ok().and_then(|v| v.parse().ok()) +} + +// ---------------------------------------------------------------------------- +// Retry / Idempotency — shared infrastructure +// ---------------------------------------------------------------------------- + +/// Default retry policy for the CLI. Used by both OpenAPI and GraphQL +/// executors when no spec-level `x-fern-retries` override applies. +/// +/// 4 total attempts (initial + 3 retries), exponential backoff starting +/// at 500ms with factor 2, 10% jitter. +#[derive(Debug, Clone, PartialEq)] +pub struct RetryPolicy { + pub enabled: bool, + pub max_attempts: u32, + pub base_delay_ms: u64, + pub factor: f64, + pub jitter: f64, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + enabled: true, + max_attempts: 4, + base_delay_ms: 500, + factor: 2.0, + jitter: 0.1, + } + } +} + +impl RetryPolicy { + pub fn disabled() -> Self { + Self { + enabled: false, + max_attempts: 0, + base_delay_ms: 0, + factor: 2.0, + jitter: 0.0, + } + } +} + +/// Returns `true` when the HTTP status code is considered retryable. +/// +/// Retryable statuses: +/// - 408 Request Timeout +/// - 429 Too Many Requests +/// - 500–599 (all server errors) +pub fn is_retryable_status(status: u16) -> bool { + status == 408 || status == 429 || (500..=599).contains(&status) +} + +/// Whether the HTTP method is safe to retry without explicit idempotency +/// marking. GET, HEAD, OPTIONS, DELETE, and PUT are idempotent by spec. +pub fn method_allows_retry(http_method: &str, marked_idempotent: bool) -> bool { + if marked_idempotent { + return true; + } + matches!( + http_method.to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" | "DELETE" | "PUT" + ) +} + +/// Parse a `Retry-After` header value into a `Duration`. +/// +/// Accepts either a non-negative integer (seconds) or an HTTP-date +/// (IMF-fixdate / RFC 850 / asctime). +pub fn parse_retry_after(value: &str, now: std::time::SystemTime) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + if let Ok(secs) = trimmed.parse::() { + return Some(Duration::from_secs(secs)); + } + if let Ok(target) = httpdate::parse_http_date(trimmed) { + return Some(target.duration_since(now).unwrap_or(Duration::ZERO)); + } + None +} + +/// Compute exponential backoff delay for the given attempt. +/// +/// `attempt` is 0-indexed (the just-completed send). Delay grows as +/// `base_delay_ms * factor^attempt`, with symmetric jitter. +pub fn compute_backoff_delay(attempt: u32, policy: &RetryPolicy) -> Duration { + let jitter_sample = if policy.jitter > 0.0 { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64; + ((nanos.wrapping_mul(2654435761)) & 0xFFFF) as f64 / 65535.0 + } else { + 0.5 + }; + compute_backoff_delay_with_rand(attempt, policy, jitter_sample) +} + +/// Test-friendly variant: `rand_unit` in `[0.0, 1.0]`; 0.5 = no jitter offset. +pub fn compute_backoff_delay_with_rand( + attempt: u32, + policy: &RetryPolicy, + rand_unit: f64, +) -> Duration { + if !policy.enabled { + return Duration::ZERO; + } + let raw_ms = (policy.base_delay_ms as f64) * policy.factor.powi(attempt as i32); + let jitter_span = raw_ms * policy.jitter; + let offset = (rand_unit.clamp(0.0, 1.0) - 0.5) * jitter_span; + let ms = (raw_ms + offset).max(0.0); + let capped = if ms > u64::MAX as f64 { u64::MAX } else { ms as u64 }; + Duration::from_millis(capped) +} + +/// Outcome of a retry-loop iteration. +#[derive(Debug)] +pub struct RetryOutcome<'a> { + pub status: Option, + pub retry_after: Option<&'a str>, +} + +/// Decide whether to retry. Returns `Some(delay)` to schedule a retry, +/// or `None` to surface the outcome. +pub fn decide_retry( + attempt: u32, + outcome: &RetryOutcome<'_>, + policy: &RetryPolicy, + http_method: &str, + marked_idempotent: bool, + no_retry: bool, +) -> Option { + if no_retry || !policy.enabled || policy.max_attempts == 0 { + return None; + } + if attempt + 1 >= policy.max_attempts { + return None; + } + match outcome.status { + None => { + if !method_allows_retry(http_method, marked_idempotent) { + return None; + } + Some(compute_backoff_delay(attempt, policy)) + } + Some(status) => { + if !is_retryable_status(status) { + return None; + } + let always_safe = matches!(status, 408 | 429); + if !always_safe && !method_allows_retry(http_method, marked_idempotent) { + return None; + } + if let Some(raw) = outcome.retry_after { + if let Some(d) = parse_retry_after(raw, std::time::SystemTime::now()) { + return Some(d); + } + } + Some(compute_backoff_delay(attempt, policy)) + } + } +} + +/// Generate a UUID v4 idempotency key. +/// +/// Uses cheap entropy from system time + process-id + a monotonic counter +/// rather than pulling in the `uuid` crate. The counter guarantees +/// uniqueness even when two calls land in the same clock tick (e.g. fast +/// pagination with `--page-delay 0`). The result is formatted as a +/// standard 8-4-4-4-12 lowercase hex UUID with version nibble = 4 and +/// variant bits set per RFC 4122 section 4.4. +pub fn generate_idempotency_key() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let nanos = now.subsec_nanos() as u64; + let secs = now.as_secs(); + let pid = std::process::id() as u64; + let seq = COUNTER.fetch_add(1, Ordering::Relaxed); + + // Mix bits with a multiplicative hash (Knuth's golden-ratio constant + // variants) to spread entropy across the 128-bit space. + let a = secs + .wrapping_mul(6364136223846793005) + .wrapping_add(nanos) + .wrapping_add(seq); + let b = nanos + .wrapping_mul(2654435761) + .wrapping_add(pid) + .wrapping_mul(1442695040888963407) + .wrapping_add(secs) + .wrapping_add(seq.wrapping_mul(6364136223846793005)); + + // Stamp version (4) and variant (10xx) bits per RFC 4122. + let hi = (a & 0xFFFFFFFF_FFFF0FFF) | 0x00000000_00004000; + let lo = (b & 0x3FFFFFFF_FFFFFFFF) | 0x80000000_00000000; + + format!( + "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}", + (hi >> 32) as u32, + ((hi >> 16) & 0xFFFF) as u16, + hi as u16, + (lo >> 48) as u16, + lo & 0x0000FFFFFFFFFFFF, + ) +} + +/// Returns `true` when the HTTP method should carry an auto-generated +/// `Idempotency-Key` header (POST, PUT, PATCH). +pub fn needs_idempotency_key(http_method: &str) -> bool { + matches!( + http_method.to_ascii_uppercase().as_str(), + "POST" | "PUT" | "PATCH" + ) +} + +// ---------------------------------------------------------------------------- +// Tests +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// RAII guard that sets env vars on construction and unsets them on + /// drop, so a panic mid-test doesn't leak mutations into other tests. + /// The `unset` helper additionally restores any pre-existing value + /// on drop so the guard works for both setting and clearing. + #[derive(Default)] + struct EnvGuard { + set_keys: Vec, + unset_keys: Vec<(String, Option)>, + } + + impl EnvGuard { + fn set(&mut self, k: &str, v: impl AsRef) { + std::env::set_var(k, v); + self.set_keys.push(k.to_string()); + } + /// Temporarily clear `k` for the duration of the guard, restoring + /// any previously-set value on drop. Used to isolate tests from + /// ambient CI/local env (e.g. Linux runners that set + /// `SSL_CERT_FILE` globally — that var would otherwise leak into + /// `HttpConfig::resolve`'s CA-bundle fallback). + fn unset(&mut self, k: &str) { + let prior = std::env::var_os(k); + std::env::remove_var(k); + self.unset_keys.push((k.to_string(), prior)); + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + for k in &self.set_keys { + std::env::remove_var(k); + } + for (k, prior) in &self.unset_keys { + if let Some(v) = prior { + std::env::set_var(k, v); + } else { + std::env::remove_var(k); + } + } + } + } + + /// Standard env-isolation for `resolve()` tests — clears the + /// CA-bundle fallback chain that CI may have pre-populated. Use at + /// the top of any test that asserts on a clean / minimal resolved + /// config. + fn isolated_env_guard() -> EnvGuard { + let mut g = EnvGuard::default(); + g.unset("SSL_CERT_FILE"); + g + } + + #[test] + #[serial_test::serial] + fn build_client_succeeds_with_clean_env() { + let cfg = HttpConfig::new("bigcommerce").unwrap(); + assert!(cfg.build_client().is_ok()); + } + + #[test] + fn http_config_rejects_empty_name() { + let err = HttpConfig::new("").expect_err("empty name should error"); + assert!(err.to_string().contains("empty name")); + } + + #[test] + fn env_prefix_uppercases_and_translates_dashes() { + let cfg = HttpConfig::new("openapi-fixture").unwrap(); + assert_eq!(cfg.env_prefix(), "OPENAPI_FIXTURE"); + assert_eq!(cfg.name(), "openapi-fixture"); + } + + #[test] + #[serial_test::serial] + fn user_agent_uses_binary_name_and_crate_version() { + let mut env = EnvGuard::default(); + env.unset("ELEVENLABS_USER_AGENT_SUFFIX"); + let cfg = HttpConfig::new("elevenlabs").unwrap(); + let ua = cfg.user_agent(); + // The product token is normalized to end with `-cli`. + assert_eq!(ua, format!("elevenlabs-cli/{}", env!("CARGO_PKG_VERSION"))); + // Must not fall back to the shared crate name. + assert!(!ua.starts_with("fern-cli-sdk/")); + } + + #[test] + #[serial_test::serial] + fn user_agent_does_not_double_cli_suffix() { + let mut env = EnvGuard::default(); + env.unset("ELEVENLABS_CLI_USER_AGENT_SUFFIX"); + // A binary name that already ends with `-cli` is used verbatim. + let cfg = HttpConfig::new("elevenlabs-cli").unwrap(); + assert_eq!( + cfg.user_agent(), + format!("elevenlabs-cli/{}", env!("CARGO_PKG_VERSION")), + ); + } + + #[test] + #[serial_test::serial] + fn user_agent_appends_consumer_suffix_from_env() { + let mut env = EnvGuard::default(); + env.set("ELEVENLABS_USER_AGENT_SUFFIX", "partner-app/3.1"); + let cfg = HttpConfig::new("elevenlabs").unwrap(); + assert_eq!( + cfg.user_agent(), + format!("elevenlabs-cli/{} partner-app/3.1", env!("CARGO_PKG_VERSION")), + ); + } + + #[test] + #[serial_test::serial] + fn user_agent_ignores_blank_or_invalid_suffix() { + let mut env = EnvGuard::default(); + let base = format!("elevenlabs-cli/{}", env!("CARGO_PKG_VERSION")); + + // Whitespace-only suffix is dropped. + env.set("ELEVENLABS_USER_AGENT_SUFFIX", " "); + assert_eq!(HttpConfig::new("elevenlabs").unwrap().user_agent(), base); + + // A suffix with control characters is not a valid header value and is + // ignored rather than dropping the CLI's own User-Agent. + env.set("ELEVENLABS_USER_AGENT_SUFFIX", "bad\nvalue"); + assert_eq!(HttpConfig::new("elevenlabs").unwrap().user_agent(), base); + } + + #[test] + #[serial_test::serial] + fn user_agent_flag_override_takes_precedence_over_env() { + let mut env = EnvGuard::default(); + env.set("ELEVENLABS_USER_AGENT_SUFFIX", "from-env/1.0"); + // The `--user-agent-suffix` flag override wins over the env var. + let cfg = HttpConfig::new("elevenlabs") + .unwrap() + .with_user_agent_suffix_override(Some("from-flag/2.0".to_string())); + assert_eq!( + cfg.user_agent(), + format!("elevenlabs-cli/{} from-flag/2.0", env!("CARGO_PKG_VERSION")), + ); + } + + #[test] + #[serial_test::serial] + fn user_agent_blank_flag_override_falls_back_to_env() { + let mut env = EnvGuard::default(); + env.set("ELEVENLABS_USER_AGENT_SUFFIX", "from-env/1.0"); + // A blank/whitespace flag value clears the override, so the env-var + // fallback still applies. + let cfg = HttpConfig::new("elevenlabs") + .unwrap() + .with_user_agent_suffix_override(Some(" ".to_string())); + assert_eq!( + cfg.user_agent(), + format!("elevenlabs-cli/{} from-env/1.0", env!("CARGO_PKG_VERSION")), + ); + } + + #[test] + #[serial_test::serial] + fn user_agent_flag_override_ignores_invalid_value() { + let mut env = EnvGuard::default(); + env.unset("ELEVENLABS_USER_AGENT_SUFFIX"); + let base = format!("elevenlabs-cli/{}", env!("CARGO_PKG_VERSION")); + // An override that is not valid header content is dropped rather than + // corrupting the CLI's own User-Agent. + let cfg = HttpConfig::new("elevenlabs") + .unwrap() + .with_user_agent_suffix_override(Some("bad\nvalue".to_string())); + assert_eq!(cfg.user_agent(), base); + } + + #[test] + #[serial_test::serial] + fn user_agent_invalid_flag_override_falls_back_to_env() { + let mut env = EnvGuard::default(); + env.set("ELEVENLABS_USER_AGENT_SUFFIX", "from-env/1.0"); + // A header-invalid flag value clears the override just like a blank + // one, so a valid env suffix is not suppressed by unusable flag input. + let cfg = HttpConfig::new("elevenlabs") + .unwrap() + .with_user_agent_suffix_override(Some("bad\nvalue".to_string())); + assert_eq!( + cfg.user_agent(), + format!("elevenlabs-cli/{} from-env/1.0", env!("CARGO_PKG_VERSION")), + ); + } + + #[test] + fn with_extra_root_cert_rejects_non_pem() { + let cfg = HttpConfig::new("regtest").unwrap(); + let err = cfg + .with_extra_root_cert(b"not a pem") + .expect_err("non-PEM should error"); + assert!(err.to_string().contains("extra root cert")); + } + + #[test] + fn with_extra_root_cert_rejects_empty_bundle() { + let cfg = HttpConfig::new("regtest").unwrap(); + let err = cfg + .with_extra_root_cert(b"") + .expect_err("empty bytes should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("empty") || msg.contains("no pem")); + } + + #[test] + #[serial_test::serial] + fn first_env_truthy_returns_active_key_name() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_ACTIVE_PRIMARY", "1"); + env.set("CLI_TEST_ACTIVE_ALIAS", "true"); + let primary = "CLI_TEST_ACTIVE_PRIMARY".to_string(); + let alias = "CLI_TEST_ACTIVE_ALIAS".to_string(); + assert_eq!( + first_env_truthy([&primary, &alias]).as_deref(), + Some("CLI_TEST_ACTIVE_PRIMARY"), + ); + std::env::remove_var("CLI_TEST_ACTIVE_PRIMARY"); + // Alias wins now that primary is unset. + assert_eq!( + first_env_truthy([&primary, &alias]).as_deref(), + Some("CLI_TEST_ACTIVE_ALIAS"), + ); + } + + #[test] + #[serial_test::serial] + fn first_env_truthy_rejects_falsy() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_FALSY", "0"); + let key = "CLI_TEST_FALSY".to_string(); + assert!(first_env_truthy([&key]).is_none()); + env.set("CLI_TEST_FALSY", "false"); + assert!(first_env_truthy([&key]).is_none()); + env.set("CLI_TEST_FALSY", ""); + assert!(first_env_truthy([&key]).is_none()); + } + + #[test] + fn is_truthy_is_case_insensitive() { + assert!(is_truthy("1")); + assert!(is_truthy("TRUE")); + assert!(is_truthy("True")); + assert!(is_truthy("yes")); + assert!(is_truthy("ON")); + assert!(!is_truthy("0")); + assert!(!is_truthy("")); + assert!(!is_truthy("anything-else")); + } + + #[test] + #[serial_test::serial] + fn parse_secs_handles_numeric_and_invalid() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_SECS", "42"); + assert_eq!(parse_secs("CLI_TEST_SECS"), Some(42)); + env.set("CLI_TEST_SECS", "not-a-number"); + assert_eq!(parse_secs("CLI_TEST_SECS"), None); + assert_eq!(parse_secs("CLI_TEST_NEVER_SET"), None); + } + + #[test] + #[serial_test::serial] + fn first_env_picks_first_set_value_and_skips_empty() { + let mut env = EnvGuard::default(); + env.set("CLI_TEST_FIRST_A", ""); + env.set("CLI_TEST_FIRST_B", "winner"); + env.set("CLI_TEST_FIRST_C", "loser"); + let a = "CLI_TEST_FIRST_A".to_string(); + let b = "CLI_TEST_FIRST_B".to_string(); + let c = "CLI_TEST_FIRST_C".to_string(); + assert_eq!(first_env([&a, &b, &c]), Some("winner".to_string())); + } + + #[test] + #[serial_test::serial] + fn ca_bundle_env_invalid_path_returns_error() { + let mut env = EnvGuard::default(); + env.set("CLI_E2E_TEST_CA_BUNDLE", "/no/such/file.pem"); + let cfg = HttpConfig::new("cli-e2e-test").unwrap(); + let err = cfg.build_client().expect_err("missing path should error"); + let msg = err.to_string(); + assert!(msg.contains("/no/such/file.pem"), "error: {msg}"); + } + + #[test] + #[serial_test::serial] + fn ca_bundle_env_empty_file_returns_error() { + let mut env = EnvGuard::default(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + env.set("CLI_EMPTY_BUNDLE_TEST_CA_BUNDLE", tmp.path()); + let cfg = HttpConfig::new("cli-empty-bundle-test").unwrap(); + let err = cfg.build_client().expect_err("empty bundle should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("no pem") || msg.contains("empty"), "error: {msg}"); + } + + #[test] + #[serial_test::serial] + fn is_first_emission_dedupes_by_binary_and_kind() { + // The emission tracker is a process-global LazyLock, so this test + // shares state with anything else that calls `is_first_emission`. + // Serializing keeps it deterministic; unique binary-name keys would + // be required if other tests called it. + assert!(is_first_emission("emit-dedupe-test", "marker-1")); + assert!(!is_first_emission("emit-dedupe-test", "marker-1")); + assert!(is_first_emission("emit-dedupe-test", "marker-2")); + assert!(is_first_emission("emit-dedupe-test-other", "marker-1")); + } + + #[test] + fn scoped_helper_concatenates_prefix_and_suffix() { + assert_eq!(scoped("BIGCOMMERCE", "_CA_BUNDLE"), "BIGCOMMERCE_CA_BUNDLE"); + assert_eq!(scoped("BOX", "_INSECURE"), "BOX_INSECURE"); + } + + // ----- resolve() — transport-neutral view --------------------------------- + + /// Minimal valid self-signed PEM. Used by both reqwest and rustls parsers + /// to verify the round-trip stays byte-identical after going through + /// [`HttpConfig::resolve`]. + const TEST_PEM: &str = "-----BEGIN CERTIFICATE-----\n\ +MIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\n\ +DgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\n\ +EjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n\ +7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n\ +5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\n\ +BgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\n\ +NDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\n\ +Wf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n\ +6MF9+Yw1Yy0t\n\ +-----END CERTIFICATE-----\n"; + + #[test] + #[serial_test::serial] + fn resolve_clean_env_yields_no_overrides() { + // CI runners (notably ubuntu-latest) set SSL_CERT_FILE globally; + // `resolve()` reads it as the CA-bundle fallback so we must clear + // it for the duration of this test to actually see "clean env". + let _g = isolated_env_guard(); + let cfg = HttpConfig::new("resolve-clean").unwrap(); + let resolved = cfg.resolve().expect("clean env should resolve"); + assert!(resolved.extra_root_certs_pem.is_empty()); + assert!(!resolved.insecure_skip_verify); + assert!(resolved.proxy.is_none()); + assert!(resolved.connect_timeout.is_none()); + assert!(resolved.request_timeout.is_none()); + } + + #[test] + #[serial_test::serial] + fn resolve_preserves_compile_time_pem_bytes_unchanged() { + let _g = isolated_env_guard(); + let cfg = HttpConfig::new("resolve-ct-pem") + .unwrap() + .with_extra_root_cert(TEST_PEM.as_bytes()) + .expect("test PEM should parse"); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 1); + // Round-trip must be byte-identical — non-reqwest transports parse + // these bytes with their own PEM reader and need them verbatim. + assert_eq!(resolved.extra_root_certs_pem[0], TEST_PEM.as_bytes()); + } + + #[test] + #[serial_test::serial] + fn resolve_appends_env_ca_bundle_after_compile_time_roots() { + let mut env = isolated_env_guard(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, TEST_PEM.as_bytes()).unwrap(); + env.set("RESOLVE_ENV_PEM_CA_BUNDLE", tmp.path()); + + let cfg = HttpConfig::new("resolve-env-pem") + .unwrap() + .with_extra_root_cert(TEST_PEM.as_bytes()) + .unwrap(); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 2, + "compile-time PEM first, env PEM appended"); + } + + #[test] + #[serial_test::serial] + fn resolve_invalid_ca_bundle_path_errors() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_BAD_PATH_CA_BUNDLE", "/no/such/file.pem"); + let cfg = HttpConfig::new("resolve-bad-path").unwrap(); + let err = cfg.resolve().expect_err("missing path should error"); + assert!(err.to_string().contains("/no/such/file.pem")); + } + + #[test] + #[serial_test::serial] + fn resolve_invalid_ca_bundle_contents_errors() { + let mut env = EnvGuard::default(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, b"not a pem").unwrap(); + env.set("RESOLVE_BAD_PEM_CA_BUNDLE", tmp.path()); + let cfg = HttpConfig::new("resolve-bad-pem").unwrap(); + let err = cfg.resolve().expect_err("unparseable PEM should error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("ca bundle") || msg.contains("pem")); + } + + #[test] + #[serial_test::serial] + fn resolve_picks_up_insecure_flag() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_INSECURE_INSECURE", "1"); + let cfg = HttpConfig::new("resolve-insecure").unwrap(); + let resolved = cfg.resolve().unwrap(); + assert!(resolved.insecure_skip_verify); + } + + #[test] + #[serial_test::serial] + fn resolve_proxy_with_explicit_no_proxy_wins_over_env() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_PROXY_PROXY", "http://proxy.example:3128"); + env.set("RESOLVE_PROXY_NO_PROXY", "internal.example"); + env.set("NO_PROXY", "should-be-ignored"); + let cfg = HttpConfig::new("resolve-proxy").unwrap(); + let resolved = cfg.resolve().unwrap(); + let p = resolved.proxy.expect("proxy should be set"); + assert_eq!(p.url, "http://proxy.example:3128"); + assert_eq!(p.no_proxy.as_deref(), Some("internal.example")); + } + + #[test] + #[serial_test::serial] + fn resolve_proxy_falls_back_to_global_no_proxy() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_PROXY_FALLBACK_PROXY", "http://p.example:3128"); + env.set("NO_PROXY", "fallback.example"); + let cfg = HttpConfig::new("resolve-proxy-fallback").unwrap(); + let resolved = cfg.resolve().unwrap(); + let p = resolved.proxy.expect("proxy should be set"); + assert_eq!(p.no_proxy.as_deref(), Some("fallback.example")); + } + + #[test] + #[serial_test::serial] + fn resolve_and_build_client_agree_on_common_env_var_shape() { + // Cheap drift check: with the same env vars set, both readers + // succeed. This doesn't prove they map values identically into + // their respective output types (reqwest::Client vs + // ResolvedTlsConfig) — that would require introspecting reqwest + // internals — but it does catch the class of bug where one + // reader accepts an env-var combination the other rejects. + let mut env = isolated_env_guard(); + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, TEST_PEM.as_bytes()).unwrap(); + env.set("RESOLVE_AGREE_CA_BUNDLE", tmp.path()); + env.set("RESOLVE_AGREE_TIMEOUT_SECS", "42"); + env.set("RESOLVE_AGREE_CONNECT_TIMEOUT_SECS", "7"); + + let cfg = HttpConfig::new("resolve-agree").unwrap(); + let resolved = cfg.resolve().expect("resolve should succeed"); + assert_eq!(resolved.extra_root_certs_pem.len(), 1); + assert_eq!(resolved.request_timeout, Some(Duration::from_secs(42))); + assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(7))); + + // build_client reads env vars independently. If it errors here + // with the same env set, the two readers have drifted on a + // value the spec says both accept. + cfg.build_client() + .expect("build_client should accept the same env vars as resolve()"); + } + + #[test] + #[serial_test::serial] + fn resolve_timeouts_parsed_as_seconds() { + let mut env = EnvGuard::default(); + env.set("RESOLVE_TIMEOUTS_TIMEOUT_SECS", "30"); + env.set("RESOLVE_TIMEOUTS_CONNECT_TIMEOUT_SECS", "5"); + let cfg = HttpConfig::new("resolve-timeouts").unwrap(); + let resolved = cfg.resolve().unwrap(); + assert_eq!(resolved.request_timeout, Some(Duration::from_secs(30))); + assert_eq!(resolved.connect_timeout, Some(Duration::from_secs(5))); + } + + // --------------------------------------------------------------- + // Retry policy tests + // --------------------------------------------------------------- + + #[test] + fn retry_policy_default_has_4_attempts() { + let p = RetryPolicy::default(); + assert!(p.enabled); + assert_eq!(p.max_attempts, 4); + assert_eq!(p.base_delay_ms, 500); + } + + #[test] + fn is_retryable_status_covers_5xx_408_429() { + for s in [408u16, 429, 500, 501, 502, 503, 504, 599] { + assert!(is_retryable_status(s), "{s} should be retryable"); + } + for s in [200u16, 301, 400, 401, 403, 404, 422] { + assert!(!is_retryable_status(s), "{s} should NOT be retryable"); + } + } + + #[test] + fn method_allows_retry_idempotent_verbs() { + for m in ["GET", "HEAD", "OPTIONS", "DELETE", "PUT"] { + assert!(method_allows_retry(m, false), "{m} should allow retry"); + } + for m in ["POST", "PATCH"] { + assert!(!method_allows_retry(m, false), "{m} should NOT allow retry"); + assert!(method_allows_retry(m, true), "{m}+marked should allow"); + } + } + + #[test] + fn needs_idempotency_key_post_put_patch() { + assert!(needs_idempotency_key("POST")); + assert!(needs_idempotency_key("PUT")); + assert!(needs_idempotency_key("PATCH")); + assert!(!needs_idempotency_key("GET")); + assert!(!needs_idempotency_key("DELETE")); + } + + #[test] + fn generate_idempotency_key_is_uuid_shaped() { + let key = generate_idempotency_key(); + let parts: Vec<&str> = key.split('-').collect(); + assert_eq!(parts.len(), 5, "should be 8-4-4-4-12 format: {key}"); + assert_eq!(parts[0].len(), 8); + assert_eq!(parts[1].len(), 4); + assert_eq!(parts[2].len(), 4); + assert_eq!(parts[3].len(), 4); + assert_eq!(parts[4].len(), 12); + // Version nibble = 4 + assert!(parts[2].starts_with('4'), "version nibble should be 4: {key}"); + } + + #[test] + fn generate_idempotency_key_unique() { + // No sleep needed -- the monotonic counter guarantees uniqueness + // even when calls land in the same clock tick. + let a = generate_idempotency_key(); + let b = generate_idempotency_key(); + assert_ne!(a, b, "successive keys should differ"); + } + + #[test] + fn parse_retry_after_numeric() { + let now = std::time::SystemTime::now(); + assert_eq!(parse_retry_after("5", now), Some(Duration::from_secs(5))); + assert_eq!(parse_retry_after("0", now), Some(Duration::from_secs(0))); + } + + #[test] + fn parse_retry_after_empty_returns_none() { + let now = std::time::SystemTime::now(); + assert_eq!(parse_retry_after("", now), None); + assert_eq!(parse_retry_after(" ", now), None); + } + + #[test] + fn compute_backoff_delay_grows_exponentially() { + let p = RetryPolicy { + enabled: true, + max_attempts: 4, + base_delay_ms: 500, + factor: 2.0, + jitter: 0.0, + }; + let d0 = compute_backoff_delay_with_rand(0, &p, 0.5); + let d1 = compute_backoff_delay_with_rand(1, &p, 0.5); + let d2 = compute_backoff_delay_with_rand(2, &p, 0.5); + assert_eq!(d0, Duration::from_millis(500)); + assert_eq!(d1, Duration::from_millis(1000)); + assert_eq!(d2, Duration::from_millis(2000)); + } + + #[test] + fn decide_retry_no_retry_flag_short_circuits() { + let p = RetryPolicy::default(); + let outcome = RetryOutcome { status: Some(503), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "GET", false, true).is_none()); + } + + #[test] + fn decide_retry_exhausts_max_attempts() { + let p = RetryPolicy { max_attempts: 3, ..RetryPolicy::default() }; + let outcome = RetryOutcome { status: Some(503), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "GET", false, false).is_some()); + assert!(decide_retry(1, &outcome, &p, "GET", false, false).is_some()); + assert!(decide_retry(2, &outcome, &p, "GET", false, false).is_none()); + } + + #[test] + fn decide_retry_post_5xx_without_idempotent_no_retry() { + let p = RetryPolicy::default(); + let outcome = RetryOutcome { status: Some(503), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "POST", false, false).is_none()); + } + + #[test] + fn decide_retry_post_429_always_safe() { + let p = RetryPolicy::default(); + let outcome = RetryOutcome { status: Some(429), retry_after: None }; + assert!(decide_retry(0, &outcome, &p, "POST", false, false).is_some()); + } + + #[test] + fn crosses_host_compares_host_only() { + let parse = |s: &str| reqwest::Url::parse(s).expect("valid url"); + // A different host is a crossing, whatever the scheme. + assert!(crosses_host( + &parse("https://api.example.com/v1"), + &parse("https://evil.example.net/v1") + )); + // A scheme upgrade on the same host is not — reqwest's own + // port-sensitive rule would call this cross-origin and strip + // `Authorization`, which is fine; we just don't refuse the hop. + assert!(!crosses_host( + &parse("http://api.example.com/v1"), + &parse("https://api.example.com/v1") + )); + // Same host, different port: same operator, not the modelled threat. + assert!(!crosses_host( + &parse("https://api.example.com/v1"), + &parse("https://api.example.com:8443/v1") + )); + } + + #[test] + fn origin_only_drops_path_and_query() { + let url = reqwest::Url::parse( + "https://cdn.example.com:8443/signed/object?X-Amz-Signature=deadbeef", + ) + .expect("valid url"); + assert_eq!(origin_only(&url), "https://cdn.example.com:8443"); + } + + /// Every guard test below uses a CLI name unique to that test, so the + /// `_ALLOW_CROSS_HOST_*` variable it reads is its own. Sharing one + /// prefix made `#[serial]` load-bearing for *correctness*: the guard is + /// read from the process environment, so one test setting the shared + /// variable while another cleared it flipped the other's expected outcome + /// in whichever direction the interleaving landed. That is invisible on an + /// idle machine and shows up on a loaded CI runner. With per-test prefixes + /// the variables cannot collide at all, and a future test that forgets + /// `#[serial]` cannot reintroduce the flake. + /// + /// A wiremock server that answers everything with `template`. + async fn always_respond(template: wiremock::ResponseTemplate) -> wiremock::MockServer { + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with(template) + .mount(&server) + .await; + server + } + + #[tokio::test] + #[serial_test::serial] + async fn cross_host_redirect_is_refused_before_the_credential_is_resent() { + // The credential here is a *custom* header, which is the case reqwest + // cannot protect: its redirect stripping covers `Authorization` and + // friends only. Without the policy in `build_client`, reqwest happily + // follows the hop and hands `xi-api-key` to the redirect target. + let mut env = EnvGuard::default(); + env.unset("REDIRECTREFUSE_ALLOW_CROSS_HOST_REDIRECTS"); + + let target = always_respond(wiremock::ResponseTemplate::new(200).set_body_string("{}")).await; + // Same machine, different *host string* — a genuine cross-host hop + // without needing DNS or a second interface. + let target_url = format!("http://localhost:{}/", target.address().port()); + let api = always_respond( + wiremock::ResponseTemplate::new(302).insert_header("location", target_url.as_str()), + ) + .await; + + let client = HttpConfig::new("redirectrefuse") + .expect("config") + .build_client() + .expect("client"); + let error = client + .get(api.uri()) + .header("xi-api-key", "super-secret") + .send() + .await + .expect_err("a cross-host redirect must not be followed"); + + let rendered = format!("{error:?}"); + assert!( + rendered.contains("crosses a host boundary"), + "the error should explain the refusal, got: {rendered}" + ); + assert!( + rendered.contains("REDIRECTREFUSE_ALLOW_CROSS_HOST_REDIRECTS"), + "the error should name the opt-out, got: {rendered}" + ); + assert!( + target + .received_requests() + .await + .unwrap_or_default() + .is_empty(), + "the credential-bearing request must never reach the redirect target" + ); + } + + #[test] + #[serial_test::serial] + fn redirect_refusal_is_classified_client_side_with_the_full_reason() { + // The refusal reason lives in the policy error, not in reqwest's own + // Display ("error following redirect for url (...)"), so the source + // chain has to be walked or the message reaching the user is useless. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let mut env = EnvGuard::default(); + env.unset("REDIRECTCLASSIFY_ALLOW_CROSS_HOST_REDIRECTS"); + + let redirector = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::any()) + .respond_with( + wiremock::ResponseTemplate::new(302) + .insert_header("location", "http://example.com/elsewhere"), + ) + // The whole point: refused once, never retried. + .expect(1) + .mount(&redirector) + .await; + + let cfg = HttpConfig::new("redirectclassify").unwrap(); + let client = cfg.build_client().unwrap(); + let error = client + .get(format!("{}/v1/thing", redirector.uri())) + .send() + .await + .expect_err("the guard should refuse this redirect"); + + let classified = + redirect_refusal_error(&error).expect("a redirect error must be classified"); + match classified { + CliError::Validation(message) => { + assert!( + message.contains("crosses a host boundary"), + "the policy reason must survive the source-chain walk, got: {message}" + ); + assert!( + message.contains("example.com"), + "the message should name the target host, got: {message}" + ); + assert!( + message.contains("ALLOW_CROSS_HOST_REDIRECTS"), + "the message should name the opt-out, got: {message}" + ); + } + other => panic!("expected CliError::Validation, got {other:?}"), + } + }); + } + + #[test] + fn non_redirect_transport_errors_are_left_alone() { + // Connection failures must keep falling through to the retry path — + // those genuinely are transient. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let cfg = HttpConfig::new("redirecttransport").unwrap(); + let client = cfg.build_client().unwrap(); + // Nothing listening: a connect error, not a redirect error. + let error = client + .get("http://127.0.0.1:1/nope") + .send() + .await + .expect_err("connect should fail"); + assert!( + redirect_refusal_error(&error).is_none(), + "a connect error must not be reclassified as a policy refusal" + ); + }); + } + + #[test] + #[serial_test::serial] + fn pagination_target_on_the_same_host_is_allowed() { + let mut env = EnvGuard::default(); + env.unset("PAGESAMEHOST_ALLOW_CROSS_HOST_PAGINATION"); + + // Same host, deeper path. + assert!(check_pagination_target( + "pagesamehost", + "https://api.example.com/v1/things", + "https://api.example.com/v1/things?cursor=2" + ) + .is_ok()); + // Scheme upgrade and port change stay within one operator's infra — + // same rule as `crosses_host` applies to redirects. + assert!(check_pagination_target( + "pagesamehost", + "http://api.example.com/v1/things", + "https://api.example.com/v1/things?cursor=2" + ) + .is_ok()); + assert!(check_pagination_target( + "pagesamehost", + "https://api.example.com/v1/things", + "https://api.example.com:8443/v1/things?cursor=2" + ) + .is_ok()); + } + + #[test] + #[serial_test::serial] + fn relative_pagination_target_is_allowed_without_inspection() { + let mut env = EnvGuard::default(); + env.unset("PAGERELATIVE_ALLOW_CROSS_HOST_PAGINATION"); + + // A relative target inherits the base origin, so it cannot cross hosts. + for next in ["/v1/things?cursor=2", "things?cursor=2", "?cursor=2"] { + assert!( + check_pagination_target("pagerelative", "https://api.example.com/v1/things", next) + .is_ok(), + "relative target {next} should be allowed" + ); + } + } + + #[test] + #[serial_test::serial] + fn cross_host_pagination_target_is_refused() { + let mut env = EnvGuard::default(); + env.unset("PAGEREFUSE_ALLOW_CROSS_HOST_PAGINATION"); + + let err = check_pagination_target( + "pagerefuse", + "https://api.example.com/v1/things", + "https://evil.example.net/v1/things?cursor=2", + ) + .expect_err("a cross-host pagination link must be refused"); + + assert!( + err.contains("https://api.example.com") && err.contains("https://evil.example.net"), + "the error should name both origins, got: {err}" + ); + assert!( + err.contains("PAGEREFUSE_ALLOW_CROSS_HOST_PAGINATION"), + "the error should name the opt-out, got: {err}" + ); + // The origin only — a pagination link's query string can itself carry a + // credential, so it must not be echoed into logs. + assert!( + !err.contains("cursor=2"), + "the error must not echo the target's query string, got: {err}" + ); + } + + #[test] + #[serial_test::serial] + fn cross_host_pagination_target_is_allowed_when_explicitly_enabled() { + let mut env = EnvGuard::default(); + env.set("PAGEOPTIN_ALLOW_CROSS_HOST_PAGINATION", "1"); + + assert!(check_pagination_target( + "pageoptin", + "https://api.example.com/v1/things", + "https://cdn.example.net/v1/things?cursor=2" + ) + .is_ok()); + } + + #[test] + #[serial_test::serial] + fn pagination_opt_out_is_separate_from_the_redirect_opt_out() { + // Allowing redirects is not consent to let a response body steer the + // next request, so the redirect key must not unlock pagination. + let mut env = EnvGuard::default(); + env.unset("PAGEOPTOUTSPLIT_ALLOW_CROSS_HOST_PAGINATION"); + env.set("PAGEOPTOUTSPLIT_ALLOW_CROSS_HOST_REDIRECTS", "1"); + + assert!( + check_pagination_target( + "pageoptoutsplit", + "https://api.example.com/v1/things", + "https://evil.example.net/v1/things" + ) + .is_err(), + "the redirect opt-out must not enable cross-host pagination" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn cross_host_redirect_is_followed_when_explicitly_allowed() { + let mut env = EnvGuard::default(); + env.set("REDIRECTOPTIN_ALLOW_CROSS_HOST_REDIRECTS", "1"); + + let target = always_respond( + wiremock::ResponseTemplate::new(200).set_body_string(r#"{"reached":true}"#), + ) + .await; + let target_url = format!("http://localhost:{}/", target.address().port()); + let api = always_respond( + wiremock::ResponseTemplate::new(302).insert_header("location", target_url.as_str()), + ) + .await; + + let client = HttpConfig::new("redirectoptin") + .expect("config") + .build_client() + .expect("client"); + let body = client + .get(api.uri()) + .send() + .await + .expect("opt-in should allow the hop") + .text() + .await + .expect("body"); + + assert!(body.contains("reached"), "expected to land on the target, got: {body}"); + drop(env); + } + + #[tokio::test] + #[serial_test::serial] + async fn same_host_redirect_is_still_followed() { + // The guard must not break ordinary same-host redirects (trailing + // slash normalization, path rewrites, http -> https upgrades). + let mut env = EnvGuard::default(); + env.unset("REDIRECTSAMEHOST_ALLOW_CROSS_HOST_REDIRECTS"); + + let server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::path("/final")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string(r#"{"final":true}"#)) + .mount(&server) + .await; + wiremock::Mock::given(wiremock::matchers::path("/start")) + .respond_with( + wiremock::ResponseTemplate::new(302) + .insert_header("location", format!("{}/final", server.uri()).as_str()), + ) + .mount(&server) + .await; + + let client = HttpConfig::new("redirectsamehost") + .expect("config") + .build_client() + .expect("client"); + let body = client + .get(format!("{}/start", server.uri())) + .header("xi-api-key", "super-secret") + .send() + .await + .expect("same-host redirect should be followed") + .text() + .await + .expect("body"); + + assert!(body.contains("final"), "expected to land on /final, got: {body}"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..604d959 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,252 @@ +//! Fern CLI SDK +//! +//! A library for building CLIs from OpenAPI or GraphQL SDL schemas. +//! Uses `x-fern-sdk-group-name` and `x-fern-sdk-method-name` extensions +//! to build the command hierarchy. + +// Public API — building blocks +pub mod app; +pub mod arg_source; +pub mod asyncapi; +pub mod auth; +pub mod binding; +pub mod cli_args; +pub mod completions; +pub(crate) mod custom_commands; +pub mod http; +pub mod error; +pub mod formatter; +pub mod graphql; +pub mod hooks; +pub mod man; +pub mod openapi; +pub mod pager; +pub mod stability; +pub mod user_agent; +pub mod validate; +pub mod sdk_executor; +pub mod websocket; + +// Convenience re-exports for auth types +pub use auth::{ApiKeyAuth, BasicAuth, BearerAuth, OAuth2Auth, OAuth2Grant, OAuth2TokenProvider, TokenCache}; + +// Re-exported for the generated wire-test harness so it derives multipart +// field flag names (`--`) with the exact same rule the CLI registers +// them under, rather than reproducing the kebab-casing logic and risking drift. +pub use text::to_kebab_flag; + +// Internal modules +pub(crate) mod debug; +pub(crate) mod early_intercept; +pub(crate) mod logging; +pub(crate) mod output; +pub(crate) mod text; + +/// Initialize logging from environment variables. Call once at startup. +/// +/// `cli_name` is the binary name (e.g. `"my-cli"`). The function reads +/// `_LOG` and `_LOG_FILE` where `` is +/// `cli_name` uppercased with hyphens replaced by underscores. +pub fn init_logging(cli_name: &str) { + logging::init_logging(cli_name); +} + +/// Reset the `SIGPIPE` signal handler to its default disposition (`SIG_DFL`). +/// +/// Rust's runtime sets `SIGPIPE` to `SIG_IGN`, which causes writes to a +/// broken pipe (e.g. ` completion bash | head -5`) to return +/// `EPIPE` errors instead of terminating the process. For CLI tools that +/// produce large output this surfaces as a panic in `println!` or +/// `write_all`. Resetting to `SIG_DFL` lets the OS deliver the signal +/// and terminate the process cleanly — the standard behavior expected by +/// Unix pipelines. +/// +/// This is the idiomatic fix used by `bat`, `ripgrep`, `fd`, `eza`, and +/// most other Rust CLI tools. Called at the very top of each binary's +/// `run()` method before any I/O. +/// +/// On non-Unix platforms this is a no-op. +#[cfg(unix)] +pub fn reset_sigpipe() { + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + } +} + +/// No-op on non-Unix platforms. +#[cfg(not(unix))] +pub fn reset_sigpipe() {} + +/// Unscoped env vars a `.env` file may not set, because each one redirects +/// traffic, weakens transport security, or names a program to execute. +const DOTENV_DENIED_BARE: &[&str] = &[ + // Names a program the CLI executes when paging output. + "PAGER", + // Route traffic through an attacker-chosen intermediary. + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + // Replace the trust store, enabling transparent interception. + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", +]; + +/// Suffixes of `_…` env vars a `.env` file may not set. Same +/// reasoning as [`DOTENV_DENIED_BARE`], plus the two cross-host guards — a +/// `.env` that could disable them would undo the redirect and pagination +/// protections. +const DOTENV_DENIED_SUFFIXES: &[&str] = &[ + "_BASE_URL", + "_PAGER", + "_PROXY", + "_NO_PROXY", + "_INSECURE", + "_INSECURE_SKIP_VERIFY", + "_CA_BUNDLE", + "_EXTRA_CA_CERTS", + "_ALLOW_CROSS_HOST_REDIRECTS", + "_ALLOW_CROSS_HOST_PAGINATION", +]; + +/// True when `key` is one a `.env` file must not be able to set. +pub(crate) fn dotenv_key_is_denied(key: &str, prefix: &str) -> bool { + if DOTENV_DENIED_BARE.contains(&key) { + return true; + } + key.strip_prefix(prefix) + .is_some_and(|rest| DOTENV_DENIED_SUFFIXES.contains(&rest)) +} + +/// Load `.env`, ignoring keys that control transport or execution. +/// +/// A generated CLI is routinely run inside repositories the operator did not +/// write, and `dotenvy::dotenv()` searches the working directory and its +/// ancestors — so an attacker-authored `.env` would otherwise be able to point +/// the CLI at another host, disable certificate verification, route requests +/// through a proxy, turn off the cross-host redirect and pagination guards, or +/// name an arbitrary program for the CLI to execute as its pager. The last is +/// the sharpest: `_PAGER` is run as a command. +/// +/// The legitimate use of `.env` — credentials and output preferences — is +/// unaffected; only the keys in [`DOTENV_DENIED_BARE`] and +/// [`DOTENV_DENIED_SUFFIXES`] are dropped. Real process environment always +/// wins, matching `dotenvy::dotenv()`'s own precedence, so anything genuinely +/// exported by the operator's shell still applies. +/// Returns the keys that were ignored, in file order, so the caller can report +/// them *after* logging is initialized. Warning from inside this function would +/// be silently dropped: it runs before `init_logging`, so no subscriber exists +/// yet and a legitimate `_BASE_URL` in `.env` would appear to be +/// ignored for no reason. +pub fn load_dotenv_filtered(cli_name: &str) -> Vec { + let prefix = cli_name.to_uppercase().replace('-', "_"); + let mut ignored = Vec::new(); + let Ok(entries) = dotenvy::dotenv_iter() else { + // No `.env` (the common case) or it is unreadable — nothing to do. + return ignored; + }; + for entry in entries.flatten() { + let (key, value) = entry; + if dotenv_key_is_denied(&key, &prefix) { + ignored.push(key); + continue; + } + // Real environment wins, as with `dotenvy::dotenv()`. + if std::env::var_os(&key).is_none() { + std::env::set_var(&key, &value); + } + } + ignored +} + +/// Report the keys [`load_dotenv_filtered`] dropped. Call after +/// `init_logging`, or the messages go nowhere. +pub fn warn_ignored_dotenv_keys(ignored: &[String]) { + for key in ignored { + tracing::warn!( + key = %key, + "ignoring {key} from .env: it controls transport or process execution, and a \ + .env file is not a trusted source for it. Export it from your shell instead." + ); + } +} + +#[cfg(test)] +mod dotenv_filter_tests { + use super::dotenv_key_is_denied; + + #[test] + fn credentials_and_preferences_are_allowed_from_dotenv() { + // The legitimate use of `.env`: secrets and output preferences. + for key in [ + "ELEVENLABS_API_KEY", + "ELEVENLABS_TOKEN", + "ELEVENLABS_CLIENT_ID", + "ELEVENLABS_CLIENT_SECRET", + "ELEVENLABS_OUTPUT", + "ELEVENLABS_VIA", + "ELEVENLABS_TIMEOUT_SECS", + "ELEVENLABS_CONNECT_TIMEOUT_SECS", + "SOME_UNRELATED_APP_KEY", + ] { + assert!( + !dotenv_key_is_denied(key, "ELEVENLABS"), + "{key} should be loadable from .env" + ); + } + } + + #[test] + fn transport_and_execution_keys_are_denied_from_dotenv() { + // Each of these lets an attacker-authored `.env` redirect traffic, + // weaken TLS, or run a program of their choosing. + for key in [ + "ELEVENLABS_BASE_URL", + "ELEVENLABS_PAGER", + "ELEVENLABS_PROXY", + "ELEVENLABS_NO_PROXY", + "ELEVENLABS_INSECURE", + "ELEVENLABS_INSECURE_SKIP_VERIFY", + "ELEVENLABS_CA_BUNDLE", + "ELEVENLABS_EXTRA_CA_CERTS", + "PAGER", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "CURL_CA_BUNDLE", + "REQUESTS_CA_BUNDLE", + ] { + assert!( + dotenv_key_is_denied(key, "ELEVENLABS"), + "{key} must not be settable from .env" + ); + } + } + + #[test] + fn dotenv_cannot_disable_the_cross_host_guards() { + // Otherwise a `.env` could undo the redirect and pagination + // protections, which is the whole point of having them. + assert!(dotenv_key_is_denied( + "ELEVENLABS_ALLOW_CROSS_HOST_REDIRECTS", + "ELEVENLABS" + )); + assert!(dotenv_key_is_denied( + "ELEVENLABS_ALLOW_CROSS_HOST_PAGINATION", + "ELEVENLABS" + )); + } + + #[test] + fn the_deny_list_is_scoped_to_this_binary_s_prefix() { + // A denied suffix under *another* CLI's prefix is not ours to police, + // and must not be dropped from the environment we hand on. + assert!(!dotenv_key_is_denied("OTHERCLI_BASE_URL", "ELEVENLABS")); + assert!(!dotenv_key_is_denied("BASE_URL", "ELEVENLABS")); + // Kebab-cased binary names map to underscored prefixes. + assert!(dotenv_key_is_denied("MY_CLI_BASE_URL", "MY_CLI")); + } +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..d90f70a --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,123 @@ +//! Structured Logging +//! +//! Provides opt-in, PII-free logging for HTTP requests and CLI operations. +//! All output goes to stderr or a log file — stdout remains clean for +//! machine-consumable JSON output. +//! +//! ## Environment Variables +//! +//! - `_LOG`: Filter directive for stderr logging +//! (e.g., `fern=debug`). `` is the CLI binary name uppercased +//! with hyphens replaced by underscores. If unset, no stderr logging. +//! +//! - `_LOG_FILE`: Directory path for JSON-line log +//! files with daily rotation. If unset, no file logging. + +use tracing_subscriber::prelude::*; + +/// Compute the env-var prefix from a CLI binary name: uppercase, hyphens → underscores. +fn env_prefix(cli_name: &str) -> String { + cli_name.to_uppercase().replace('-', "_") +} + +/// Initialize the tracing subscriber based on environment variables. +/// +/// `cli_name` is the binary name (e.g. `"my-cli"`). The function reads +/// `_LOG` and `_LOG_FILE` where `` is +/// `cli_name` uppercased with hyphens replaced by underscores. +/// +/// If neither variable is set, this is a no-op and logging adds zero +/// overhead. +pub fn init_logging(cli_name: &str) { + let prefix = env_prefix(cli_name); + let env_log = format!("{prefix}_LOG"); + let env_log_file = format!("{prefix}_LOG_FILE"); + + let stderr_filter = std::env::var(&env_log).ok(); + let log_file_dir = std::env::var(&env_log_file).ok(); + + if stderr_filter.is_none() && log_file_dir.is_none() { + return; + } + + let registry = tracing_subscriber::registry(); + + let stderr_layer = stderr_filter.map(|filter| { + let env_filter = tracing_subscriber::EnvFilter::new(filter); + tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_target(false) + .compact() + .with_filter(env_filter) + }); + + let (file_layer, _guard) = if let Some(ref dir) = log_file_dir { + let log_filename = format!("{cli_name}.log"); + let file_appender = tracing_appender::rolling::daily(dir, log_filename); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + let layer = tracing_subscriber::fmt::layer() + .json() + .with_writer(non_blocking) + .with_target(true) + .with_filter(tracing_subscriber::EnvFilter::new("debug")); + (Some(layer), Some(guard)) + } else { + (None, None) + }; + + let subscriber = registry.with(stderr_layer).with(file_layer); + if tracing::subscriber::set_global_default(subscriber).is_ok() { + if let Some(guard) = _guard { + std::mem::forget(guard); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn test_env_prefix() { + assert_eq!(env_prefix("test-cli"), "TEST_CLI"); + assert_eq!(env_prefix("box"), "BOX"); + assert_eq!(env_prefix("my-long-name"), "MY_LONG_NAME"); + } + + #[test] + fn test_env_var_names_derived() { + let prefix = env_prefix("test-cli"); + assert_eq!(format!("{prefix}_LOG"), "TEST_CLI_LOG"); + assert_eq!(format!("{prefix}_LOG_FILE"), "TEST_CLI_LOG_FILE"); + } + + #[test] + #[serial] + fn test_init_logging_default_no_panic() { + std::env::remove_var("TEST_CLI_LOG"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + init_logging("test-cli"); + } + + #[test] + #[serial] + fn test_init_logging_with_stderr_filter_no_panic() { + // set_global_default may fail if another test already set it — that's fine, + // we still exercise the branch up to and including that call. + std::env::set_var("TEST_CLI_LOG", "fern=debug"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + init_logging("test-cli"); + std::env::remove_var("TEST_CLI_LOG"); + } + + #[test] + #[serial] + fn test_init_logging_with_file_dir_no_panic() { + let dir = tempfile::tempdir().unwrap(); + std::env::remove_var("TEST_CLI_LOG"); + std::env::set_var("TEST_CLI_LOG_FILE", dir.path().to_str().unwrap()); + init_logging("test-cli"); + std::env::remove_var("TEST_CLI_LOG_FILE"); + } +} diff --git a/src/man.rs b/src/man.rs new file mode 100644 index 0000000..9bd15fd --- /dev/null +++ b/src/man.rs @@ -0,0 +1,101 @@ +//! Man page generation. +//! +//! Shared infrastructure for emitting roff-formatted man pages. Sits above +//! both protocol paths (`openapi/` and `graphql/`) and has no +//! protocol-specific dependencies. Mirrors the shape of `completions.rs`. + +use clap::Command; + +/// Returns `true` when `args` contains `"man"` as the first positional +/// token (i.e. the subcommand position). This allows early interception +/// before normal API dispatch — avoiding collision with an API resource +/// that might also be named `man`. +/// +/// Delegates to the shared [`crate::early_intercept::first_positional_is`] +/// helper which handles `--flag value` skip logic and boolean-flag awareness. +pub fn wants_man(args: &[String]) -> bool { + crate::early_intercept::first_positional_is(args, "man") +} + +/// Generate a roff-formatted man page for `cmd` and write it to `writer`. +/// +/// `bin_name` is the name the user types to invoke the CLI (e.g. `"box"`). +/// The caller is responsible for building a `Command` that mirrors the full +/// CLI surface (subcommands, flags, etc.) so the generated page is complete. +/// +/// Returns an IO error if writing fails. +pub fn generate_man_to(cmd: Command, bin_name: &str, writer: &mut dyn std::io::Write) -> std::io::Result<()> { + let cmd = cmd.name(bin_name.to_owned()); + let man = clap_mangen::Man::new(cmd); + let mut buf = Vec::new(); + man.render(&mut buf)?; + writer.write_all(&buf) +} + +/// Generate a roff-formatted man page for `cmd` and write it to stdout. +/// +/// Thin wrapper around [`generate_man_to`] that targets `stdout`. +pub fn generate_man(cmd: Command, bin_name: &str) -> std::io::Result<()> { + generate_man_to(cmd, bin_name, &mut std::io::stdout()) +} + +/// Build the `man` subcommand definition. Registered at the root of the +/// command tree so ` man` works. +pub fn man_command() -> Command { + Command::new("man") + .about("Generate a man page (roff format)") + .after_help( + "EXAMPLES:\n \ + # macOS / Linux (user-local)\n \ + man > ~/.local/share/man/man1/.1\n \ + # System-wide (Linux)\n \ + man | sudo tee /usr/local/share/man/man1/.1\n \ + # View directly without installing\n \ + man | groff -Tutf8 -man | less", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(slice: &[&str]) -> Vec { + slice.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn wants_man_basic() { + assert!(wants_man(&args(&["box", "man"]))); + } + + #[test] + fn wants_man_false_when_flag_value() { + assert!(!wants_man(&args(&["box", "--base-url", "man"]))); + } + + #[test] + fn wants_man_with_boolean_flag() { + assert!(wants_man(&args(&["box", "--dry-run", "man"]))); + } + + #[test] + fn generate_man_produces_roff() { + let cmd = Command::new("box").about("test"); + let mut buf = Vec::new(); + generate_man_to(cmd, "box", &mut buf).expect("generate_man_to should succeed"); + let output = String::from_utf8(buf).expect("man page should be valid UTF-8"); + assert!( + output.contains(".TH"), + "man page should contain a .TH title-header macro, got:\n{}", + &output[..output.len().min(200)] + ); + assert!( + output.contains("box"), + "man page should contain the binary name" + ); + assert!( + output.contains("test"), + "man page should contain the about text" + ); + } +} diff --git a/src/openapi/app.rs b/src/openapi/app.rs new file mode 100644 index 0000000..e60b950 --- /dev/null +++ b/src/openapi/app.rs @@ -0,0 +1,5526 @@ +//! High-level API for building CLIs from OpenAPI specs. +//! +//! [`CliApp`] provides a builder-style API that lets consumers create a +//! fully-functional CLI in just a few lines. [`AppContext`] exposes the +//! loaded spec and executor so that custom command handlers can call the +//! API programmatically. + +use std::collections::HashMap; + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider, SchemeBinding}; +use crate::error::CliError; +use crate::formatter; +use crate::openapi::discovery::{GlobalParameter, JsonSchema, RestDescription, RestMethod, RestResource}; +use crate::openapi::executor; + +/// Split a slash-delimited prefix string into its path components, dropping +/// empty segments so accidental leading/trailing slashes are forgiving. +fn split_prefix(prefix: &str) -> Vec { + prefix + .split('/') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() +} + +/// Merge `incoming` resources into `target` at the given nested namespace +/// path. Empty path = flat top-level merge. Multi-segment path = walk/create +/// intermediate resources, merge at the leaf. +/// +/// **Stutter elision:** at the leaf, if `incoming` contains a top-level +/// resource whose name matches the leaf namespace, that resource's methods +/// and sub-resources are *hoisted* into the namespace itself — eliminating +/// the `bigcommerce v3 customers customers get` repetition that would +/// otherwise occur when a spec's primary domain matches the namespace name. +/// Other top-level resources from the spec become children of the +/// namespace as usual. +fn merge_into_path( + target: &mut HashMap, + path: &[String], + mut incoming: HashMap, +) -> Result<(), CliError> { + if path.is_empty() { + for key in incoming.keys() { + if target.contains_key(key) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{key}' appears in multiple specs" + ))); + } + } + target.extend(incoming); + return Ok(()); + } + + if path.len() == 1 { + let leaf = path[0].clone(); + let entry = target.entry(leaf.clone()).or_insert_with(|| RestResource { + resources: HashMap::new(), + methods: HashMap::new(), + }); + + // Hoist a matching-name resource from the spec into the namespace. + if let Some(matching) = incoming.remove(&leaf) { + for (k, v) in matching.methods { + if entry.methods.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Method key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.methods.insert(k, v); + } + for (k, v) in matching.resources { + if entry.resources.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.resources.insert(k, v); + } + } + + for (k, v) in incoming { + if entry.resources.contains_key(&k) { + return Err(CliError::Discovery(format!( + "Resource key collision: '{k}' under namespace '{leaf}'" + ))); + } + entry.resources.insert(k, v); + } + return Ok(()); + } + + let head = path[0].clone(); + let entry = target.entry(head).or_insert_with(|| RestResource { + resources: HashMap::new(), + methods: HashMap::new(), + }); + merge_into_path(&mut entry.resources, &path[1..], incoming) +} + +/// Replace `{name}` substrings in `s` with values from `subs`. Variables not +/// in the map are left literal so dry-run output and downstream errors can +/// still pinpoint what's missing. +fn substitute_url_vars(s: &str, subs: &HashMap) -> String { + let mut out = s.to_string(); + for (name, value) in subs { + out = out.replace(&format!("{{{name}}}"), value); + } + out +} + +/// Walk the merged doc and substitute server variables in every `root_url` +/// (top-level + per-method, since per-operation server overrides each have +/// their own URL). +fn apply_server_var_substitutions( + doc: &mut crate::openapi::discovery::RestDescription, + subs: &HashMap, +) { + if subs.is_empty() { + return; + } + doc.root_url = substitute_url_vars(&doc.root_url, subs); + for server in &mut doc.servers { + server.url = substitute_url_vars(&server.url, subs); + } + fn walk(res: &mut crate::openapi::discovery::RestResource, subs: &HashMap) { + for method in res.methods.values_mut() { + method.root_url = substitute_url_vars(&method.root_url, subs); + for server in &mut method.servers { + server.url = substitute_url_vars(&server.url, subs); + } + } + for sub in res.resources.values_mut() { + walk(sub, subs); + } + } + for res in doc.resources.values_mut() { + walk(res, subs); + } +} + +/// Apply generator-supplied env-var overrides to every idempotent +/// operation's synthetic idempotency-header parameter. The parser +/// already populated `MethodParameter.env_var` from each +/// `IdempotencyHeader.env` declared in the spec; this pass overlays the +/// builder map so calls like `.idempotency_header_env("Idempotency-Key", +/// "API_IDEMPOTENCY_KEY")` win over a value baked into the spec. +/// +/// Keys in `envs` are matched against the entry's `name` first, then +/// its `header` value — letting generators register against whichever +/// identifier they emit at the call site. +fn apply_idempotency_header_envs( + doc: &mut crate::openapi::discovery::RestDescription, + envs: &HashMap, +) { + if envs.is_empty() || doc.idempotency_headers.is_empty() { + return; + } + + // Resolve each idempotency header's wire header name to an env var, + // checking the `name` field first and falling back to `header`. + // Collected up front so the per-method walk below is O(headers) per + // method instead of O(headers * builder_entries). + let mut header_to_env: HashMap = HashMap::new(); + for h in &doc.idempotency_headers { + let resolved = h + .name + .as_deref() + .and_then(|n| envs.get(n)) + .or_else(|| envs.get(&h.header)); + if let Some(env_var) = resolved { + header_to_env.insert(h.header.clone(), env_var.clone()); + } + } + if header_to_env.is_empty() { + return; + } + + fn walk( + res: &mut crate::openapi::discovery::RestResource, + header_to_env: &HashMap, + ) { + for method in res.methods.values_mut() { + if !method.idempotent { + continue; + } + for (header, env_var) in header_to_env { + if let Some(param) = method.parameters.get_mut(header) { + if param.location.as_deref() == Some("header") { + param.env_var = Some(env_var.clone()); + } + } + } + } + for sub in res.resources.values_mut() { + walk(sub, header_to_env); + } + } + for res in doc.resources.values_mut() { + walk(res, &header_to_env); + } +} + +fn merge_schemas( + acc: &mut HashMap, + incoming: HashMap, +) -> Result<(), CliError> { + // Multi-spec setups like BigCommerce's Management API share common schema + // names (`ErrorResponse`, `Pagination`, `Meta`) across many specs that are + // authored from the same template — collisions are the norm, not a bug. + // First write wins; schemas are only used for best-effort request-body + // validation, so a worst-case mismatch surfaces as a client-side + // validation warning, not silent corruption. A future structural-equality + // check could promote real differences back to an error. + for (key, schema) in incoming { + acc.entry(key).or_insert(schema); + } + Ok(()) +} + +/// Merge security-scheme declarations from another spec into the accumulator. +/// First write wins on collisions — multi-spec setups frequently re-declare a +/// shared `bearerAuth` from a common template, and a structural-equality check +/// would surface noise rather than help. Each operation's +/// `security_requirements` are denormalized into the operation itself at parse +/// time, so schemes only need to be merged at the top level for the eventual +/// `RoutingAuthProvider` registry. +fn merge_security_schemes( + acc: &mut HashMap, + incoming: HashMap, +) { + for (key, scheme) in incoming { + acc.entry(key).or_insert(scheme); + } +} + +/// Merge `x-fern-sdk-variables` declarations across specs. First write +/// wins on name collisions, mirroring [`merge_schemas`] and +/// [`merge_security_schemes`]. Multi-spec setups that share a common +/// variable across two OpenAPI files should only register the flag once +/// at the root, and a single source of truth is what makes resolution +/// deterministic. +fn merge_sdk_variables( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|v| v.name.clone()).collect(); + for var in incoming { + if !existing.contains(&var.name) { + acc.push(var); + } + } +} + +/// Returns true when the kebab-cased flag derived from an +/// `x-fern-sdk-variables` declaration collides with a built-in CLI flag +/// (`--params`, `--format`, `--dry-run`, …). Registering a global with +/// the same long name would panic clap's debug_assert at command tree +/// construction; the caller skips the offending entry and emits a +/// `tracing::warn!` so the spec author can rename the variable. +pub(crate) fn sdk_variable_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Merge `x-fern-global-headers` declarations across specs. First write +/// wins on header-name collisions, mirroring [`merge_sdk_variables`]. +/// Multi-spec setups that share a common header across two OpenAPI files +/// should only register the flag once at the root. +fn merge_global_headers( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|h| h.header.clone()).collect(); + for h in incoming { + if !existing.contains(&h.header) { + acc.push(h); + } + } +} + +/// Derive the kebab-cased CLI flag (`--`) for a global header. +/// Prefers `name` (the SDK display identifier) when present; otherwise +/// falls back to kebab-casing the wire header name. Mirrors the +/// `flag_name_override` pathway used by `x-fern-idempotency-headers`. +pub(crate) fn global_header_flag_name(h: &crate::openapi::discovery::GlobalHeader) -> String { + let source = h.name.as_deref().unwrap_or(h.header.as_str()); + crate::text::to_kebab_flag(source) +} + +/// Merge `x-fern-global-parameters` declarations across specs. First +/// write wins on name collisions, mirroring [`merge_global_headers`]. +fn merge_global_parameters( + acc: &mut Vec, + incoming: Vec, +) { + use std::collections::HashSet; + let existing: HashSet = acc.iter().map(|p| p.name.clone()).collect(); + for p in incoming { + if !existing.contains(&p.name) { + acc.push(p); + } + } +} + +/// Derive the kebab-cased CLI flag (`--`) for a global parameter. +/// Prefers `parameter_name` when present; otherwise falls back to +/// kebab-casing `name`. +pub(crate) fn global_parameter_flag_name(p: &crate::openapi::discovery::GlobalParameter) -> String { + let source = p.parameter_name.as_deref().unwrap_or(p.name.as_str()); + crate::text::to_kebab_flag(source) +} + +/// Derive a stable clap `Arg::new()` identifier for a global parameter. +/// Uses the format `global-param:` to avoid collisions with +/// per-operation parameter flags. +fn global_parameter_arg_id(p: &crate::openapi::discovery::GlobalParameter) -> String { + format!("global-param:{}", p.name) +} + +/// Returns true when a global-parameter flag would collide with a +/// built-in CLI flag. +fn global_parameter_flag_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Resolve a global parameter value from clap matches (CLI flag > env > +/// default, handled by clap's `.env()` + `.default_value()`). +/// +/// Uses `try_get_one` rather than `get_one` because the flag is not +/// guaranteed to be registered on every command: when its long name +/// collides with a per-operation parameter, the flag is dropped from +/// that operation's command (the per-op parameter wins — see +/// `register_global_header_on_nonconflicting_leaves`). On such a +/// command the arg id is unknown and `get_one` would panic; +/// `try_get_one` returns `Err`, which we map to `None`. +pub(crate) fn resolve_global_parameter_value( + matches: &clap::ArgMatches, + p: &crate::openapi::discovery::GlobalParameter, +) -> Option { + let arg_id = global_parameter_arg_id(p); + matches + .try_get_one::(&arg_id) + .ok() + .flatten() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// Stable clap arg ID for a global header. Anchored to the wire header +/// name so per-op parameter lookups (which key off the same string) +/// remain consistent with what clap returns. +pub(crate) fn global_header_arg_id(h: &crate::openapi::discovery::GlobalHeader) -> String { + format!("__global_header::{}", h.header) +} + +/// Returns true when the kebab-cased flag derived from an +/// `x-fern-global-headers` entry collides with a built-in CLI flag +/// (`--params`, `--format`, …) or an already-registered global. clap +/// would panic in debug builds on collision; we skip the offending entry +/// with a `tracing::warn!` so the spec still loads. +fn global_header_flag_collides_with_builtin(kebab: &str) -> bool { + crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab) +} + +/// Returns true when some operation command in the tree rooted at `cli` +/// declares a flag whose long name equals `long`. A `global(true)` arg +/// that shares a long name with a per-operation parameter makes clap +/// panic at build time ("Long option names must be unique for each +/// argument"), so a global-header flag that collides with a parameter on +/// any operation cannot be registered globally — see the call site in +/// [`CliApp::decorate_command`]. +/// +/// Only descendant (subcommand) args are inspected; the root's own global +/// flags (`--format`, `--dry-run`, …) are screened separately by +/// [`global_header_flag_collides_with_builtin`]. +fn global_header_long_collides_with_param(cli: &clap::Command, long: &str) -> bool { + cli.get_subcommands().any(|sub| command_declares_long(sub, long)) +} + +/// Recursive worker for [`global_header_long_collides_with_param`]: true +/// if `cmd` or any of its descendants declares a flag with long name +/// `long`. +fn command_declares_long(cmd: &clap::Command, long: &str) -> bool { + cmd.get_arguments().any(|a| a.get_long() == Some(long)) + || cmd + .get_subcommands() + .any(|sub| command_declares_long(sub, long)) +} + +/// Register `arg` (a global-header flag that is *not* marked +/// `global(true)`) on every leaf operation command whose flags don't +/// already include the arg's long name. Leaves that declare a same-named +/// per-operation parameter are left untouched so the per-op parameter +/// wins (mirroring the importer semantics documented on +/// [`GlobalHeader`](crate::openapi::discovery::GlobalHeader)). +/// +/// Intermediate (resource) commands are recursed into but never carry +/// the flag themselves — the resolved value is read from the leaf's +/// `ArgMatches` (see [`build_global_header_overrides`]), so attaching it +/// to the leaf is what makes the flag, its env fallback, and its default +/// available on the operations that don't collide. +fn register_global_header_on_nonconflicting_leaves( + cmd: clap::Command, + arg: &clap::Arg, + long: &str, +) -> clap::Command { + let sub_names: Vec = cmd + .get_subcommands() + .map(|c| c.get_name().to_string()) + .collect(); + if sub_names.is_empty() { + // Leaf operation command: attach the flag unless it already + // declares one with the same long name (per-op param wins). + if cmd.get_arguments().any(|a| a.get_long() == Some(long)) { + return cmd; + } + return cmd.arg(arg.clone()); + } + let mut out = cmd; + for name in sub_names { + let arg = arg.clone(); + let long = long.to_string(); + out = out.mut_subcommand(name, move |sub| { + register_global_header_on_nonconflicting_leaves(sub, &arg, &long) + }); + } + out +} + +/// Resolve a global header value from `matched_args`, the env, and the +/// configured default — in that order. Returns `None` when none of the +/// three sources produced a value, OR when the resolved value is empty +/// or whitespace-only (callers shouldn't stamp a header like `X-API-Stage:` +/// on the wire — that's almost always a user mistake worth surfacing as a +/// required-header error, and matches the env-var-handling convention). +/// +/// `matched_args.try_get_one::` already incorporates clap's +/// `.env()` and `.default_value()` bindings, so the lookup is a single +/// read; the explicit env/default fields on [`GlobalHeader`] are what +/// feed those clap bindings at registration time. +/// +/// Uses `try_get_one` rather than `get_one` because the flag is not +/// guaranteed to be registered on every command: when its long name +/// collides with a per-operation parameter, the flag is dropped from +/// that operation's command (the per-op parameter wins — see +/// `register_global_header_on_nonconflicting_leaves`). On such a command +/// the arg id is unknown and `get_one` would panic; `try_get_one` +/// returns `Err`, which we map to `None`. +pub(crate) fn resolve_global_header_value( + matched_args: &clap::ArgMatches, + h: &crate::openapi::discovery::GlobalHeader, +) -> Option { + matched_args + .try_get_one::(&global_header_arg_id(h)) + .ok() + .flatten() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) +} + +/// True when an operation declares a `header`-located parameter with +/// the same wire-name as a global header AND the user supplied a value +/// for it in `params`. HTTP header names are case-insensitive per RFC +/// 7230 §3.2, so the lookup is `eq_ignore_ascii_case` rather than +/// `HashMap::contains_key` / `HashMap::get`. +pub(crate) fn per_op_header_param_overrides_global( + params: &serde_json::Map, + method: &RestMethod, + wire_name: &str, +) -> bool { + let supplied = params + .keys() + .any(|k| k.eq_ignore_ascii_case(wire_name)); + if !supplied { + return false; + } + method + .parameters + .iter() + .any(|(k, p)| k.eq_ignore_ascii_case(wire_name) && p.location.as_deref() == Some("header")) +} + +/// Build the structured validation error used when a required global +/// header has neither a CLI/env/default value nor a per-op override. +/// Shared by both the built-in command path +/// ([`build_global_header_overrides`]) and the custom-command path +/// ([`AppContext::extra_headers_for`]) so users get the same message +/// regardless of which dispatcher they hit. +fn missing_required_global_header_error(h: &crate::openapi::discovery::GlobalHeader) -> CliError { + let flag = global_header_flag_name(h); + let env_hint = match &h.env { + Some(e) => format!(" or set ${e}"), + None => String::new(), + }; + CliError::Validation(format!( + "Missing required global header '{}': provide --{}{}", + h.header, flag, env_hint + )) +} + +/// Shared implementation of the per-op-aware required-header walk. +/// Both [`build_global_header_overrides`] (built-in path) and +/// [`AppContext::extra_headers_for`] (custom-command path) call this +/// helper, differing only in how a header's value is resolved — the +/// built-in path reads directly from clap's `ArgMatches`, the +/// custom-command path looks up the pre-resolved map. +/// +/// Walks `doc_global_headers` and for each entry: +/// * skips if the operation declares a same-named header param that +/// the user supplied (per-op wins); +/// * emits `(wire-name, value)` if the resolver returns a non-empty +/// value; +/// * errors if the header is required (`optional: false`) and neither +/// a resolved value nor a per-op override is present. +/// +/// The resolver closure is responsible for any trimming / empty-string +/// filtering — see [`resolve_global_header_value`] for the canonical +/// implementation. +fn finalize_global_header_overrides( + doc_global_headers: &[crate::openapi::discovery::GlobalHeader], + method: &RestMethod, + per_op_params: &serde_json::Map, + mut resolver: R, +) -> Result, CliError> +where + R: FnMut(&crate::openapi::discovery::GlobalHeader) -> Option, +{ + let mut out = Vec::new(); + for h in doc_global_headers { + let overridden_by_per_op = + per_op_header_param_overrides_global(per_op_params, method, &h.header); + let resolved = resolver(h); + match (resolved, overridden_by_per_op) { + (Some(value), false) => out.push((h.header.clone(), value)), + (Some(_), true) => { /* per-op wins, do not stamp */ } + (None, true) => { /* per-op satisfies the required check */ } + (None, false) => { + if !h.optional { + return Err(missing_required_global_header_error(h)); + } + } + } + } + Ok(out) +} + +/// Build the resolved `(wire-name, value)` list of `x-fern-global-headers` +/// to stamp on every outgoing request for this invocation. +/// +/// The resolution chain per header is `CLI flag > env var > default`, +/// implemented by clap's `.env()` + `.default_value()` bindings — see +/// the registration loop in `run_async`. +/// +/// Per-operation overrides: if the operation declares a `header`-located +/// parameter with the same (case-insensitive) wire-name AND the user +/// supplied a value for it (present in `params`), the global header is +/// suppressed; the per-op value wins both on the wire and in the +/// required-header satisfiability check. This mirrors Fern's importer +/// behavior where a header parameter declared on the operation replaces +/// the global. +/// +/// Errors when a `required` (i.e. `optional: false`) global header has +/// neither a CLI/env/default value nor a per-op override. +pub(crate) fn build_global_header_overrides( + matched_args: &clap::ArgMatches, + doc: &RestDescription, + method: &RestMethod, + params: &serde_json::Map, +) -> Result, CliError> { + finalize_global_header_overrides(&doc.global_headers, method, params, |h| { + resolve_global_header_value(matched_args, h) + }) +} + +/// A single resolved global parameter value, ready for injection into +/// an outgoing request. Carries the location and wire target so the +/// executor can route the value to the correct part of the request. +#[derive(Debug, Clone)] +pub struct ResolvedGlobalParam { + /// Stable identity of the declaring `x-fern-global-parameters` entry + /// (its `name`). Two parameters may share a `target` across different + /// locations (e.g. `currency` in both `query` and `body`), so the + /// declaration must be looked up by `name`, not `target`. + pub name: String, + /// Where the value is injected on the wire. + pub location: crate::openapi::discovery::GlobalParameterLocation, + /// Wire-level target (header name, query param name, body path, or + /// path template variable). + pub target: String, + /// The resolved string value. + pub value: String, +} + +/// Whether a declared global parameter's apply mode admits it on +/// `method`: `auto` always applies; `explicit` applies only when the +/// operation opts in via `x-fern-global-parameter`. Shared by both the +/// built-in command path ([`build_global_parameter_overrides`]) and the +/// custom-command path ([`CliApp::extra_global_params_for_entry`]) so the +/// two cannot drift. +pub(crate) fn global_param_apply_mode_admits( + decl: &crate::openapi::discovery::GlobalParameter, + method: &RestMethod, +) -> bool { + use crate::openapi::discovery::GlobalParameterApplyMode; + match decl.apply { + GlobalParameterApplyMode::Auto => true, + GlobalParameterApplyMode::Explicit => { + method.global_parameter_opt_ins.iter().any(|n| n == &decl.name) + } + } +} + +/// Whether a per-operation parameter supplied by the caller overrides the +/// global targeting the same wire location (per-op wins). Shared by both +/// injection paths. +pub(crate) fn per_op_param_overrides_global( + params: &serde_json::Map, + method: &RestMethod, + location: crate::openapi::discovery::GlobalParameterLocation, + target: &str, +) -> bool { + use crate::openapi::discovery::GlobalParameterLocation; + match location { + GlobalParameterLocation::Header => { + per_op_header_param_overrides_global(params, method, target) + } + GlobalParameterLocation::Query + | GlobalParameterLocation::Body + | GlobalParameterLocation::Path => params.contains_key(target), + } +} + +/// Build the resolved list of `x-fern-global-parameters` to inject on +/// this operation's request. +/// +/// For each declared global parameter: +/// - `apply: auto` → always inject (unless a per-op parameter overrides) +/// - `apply: explicit` → only inject if the operation opts in via +/// `x-fern-global-parameter` +/// +/// Per-operation parameters with the same wire-name suppress the global +/// (per-op wins). Required globals without a resolved value error. +pub(crate) fn build_global_parameter_overrides( + matched_args: &clap::ArgMatches, + doc: &RestDescription, + method: &RestMethod, + params: &serde_json::Map, +) -> Result, CliError> { + let mut out = Vec::new(); + for p in &doc.global_parameters { + // Apply mode: auto injects on all ops, explicit only on opted-in ops. + if !global_param_apply_mode_admits(p, method) { + continue; + } + + // A per-operation parameter with the same target overrides the global. + let overridden = per_op_param_overrides_global(params, method, p.location, &p.target); + + let resolved = resolve_global_parameter_value(matched_args, p); + match (resolved, overridden) { + (Some(value), false) => { + out.push(ResolvedGlobalParam { + name: p.name.clone(), + location: p.location, + target: p.target.clone(), + value, + }); + } + (Some(_), true) => { /* per-op wins */ } + (None, true) => { /* per-op satisfies */ } + (None, false) => { + if !p.optional { + let kebab = global_parameter_flag_name(p); + let mut msg = format!( + "Required global parameter '{}' has no value.", + p.name, + ); + if let Some(ref env) = p.env { + msg.push_str(&format!( + " Provide it via --{kebab} or {env}." + )); + } else { + msg.push_str(&format!(" Provide it via --{kebab}.")); + } + return Err(CliError::Validation(msg)); + } + } + } + } + Ok(out) +} + +/// Compose the root `--help` footer from the optional global-headers +/// section, the optional global-parameters section, the optional auth +/// section, and the always-present runtime footer. Sections are joined +/// with a single newline; absent sections are skipped entirely (no +/// stray blank dividers). +/// +/// Extracted so the section-skipping logic is unit-testable in +/// isolation — the clap `Command` it eventually feeds into is opaque +/// and harder to introspect from tests. +pub(crate) fn compose_root_after_help_sections( + global_headers_section: Option<&str>, + global_params_section: Option<&str>, + auth_section: Option<&str>, + footer: &str, +) -> String { + let mut sections: Vec<&str> = Vec::with_capacity(4); + if let Some(s) = global_headers_section { + sections.push(s); + } + if let Some(s) = global_params_section { + sections.push(s); + } + if let Some(s) = auth_section { + sections.push(s); + } + sections.push(footer); + sections.join("\n") +} + +/// Internal entry describing one OpenAPI spec to be merged. +pub(crate) struct SpecEntry { + yaml: String, + /// Empty = flat at the top level. One entry = wrap under that prefix. + /// Multiple = wrap under nested resources (`["v3", "customers"]` → + /// `v3.customers.*`). Path is constructed from slash-delimited input on + /// the public API. + prefix_path: Vec, + /// Overlay documents to apply before parsing. + overlays: Vec, + /// Optional overrides YAML strings that are deep-merged onto the base spec + /// before parsing. Applied sequentially — later overrides take precedence. + /// Matches the Fern CLI `generators.yml` `overrides:` key behavior: + /// maps merge key-by-key, arrays replace wholesale, `null` deletes keys. + overrides: Vec, +} + +/// A server-URL template variable like `{store_hash}` in +/// `https://api.bigcommerce.com/stores/{store_hash}/v3`. Resolved at runtime +/// from a CLI flag (`--`), an env var, or a built-in default — first +/// match wins. +#[derive(Clone)] +pub(crate) struct ServerVar { + /// OpenAPI variable name as it appears in the URL template (`store_hash`). + name: String, + /// Env var consulted when the flag isn't passed (e.g. `BIGCOMMERCE_STORE_HASH`). + env_var: Option, + /// Fallback default (for variables that have one — most BigCommerce-style + /// store identifiers don't). + default: Option, + /// One-line `--help` string. + description: Option, +} + +/// Builder for a schema-driven CLI application (OpenAPI). +pub struct CliApp { + pub(crate) name: String, + pub(crate) specs: Vec, + title_override: Option, + description_override: Option, + /// Auth bindings registered via [`auth_scheme`](Self::auth_scheme), + /// [`auth_basic_scheme`](Self::auth_basic_scheme), and + /// [`auth_provider`](Self::auth_provider). The constructed provider is + /// built from these (lowered against the spec's + /// `components.securitySchemes`). + pub(crate) auth_bindings: Vec<(String, SchemeBinding)>, + /// Override for how bindings compose. Defaults to [`AuthStrategy::Auto`] + /// — the spec drives the choice. Generators that already know the + /// API's auth model can pin a specific strategy. + auth_strategy: AuthStrategy, + /// Optional additive auth layers registered via + /// [`auth_layer`](Self::auth_layer). Each is applied on top of the + /// composed primary provider whenever it has credentials — see + /// [`LayeredAuthProvider`](crate::auth::LayeredAuthProvider). Empty by + /// default; layers never affect whether the primary auth is satisfiable. + pub(crate) auth_layers: Vec, + /// Trust roots parsed at builder-call time. Storing parsed certs (not + /// raw bytes) means the validation error message lives in one place + /// — at the call site of `extra_root_cert`, where it's most useful. + pub(crate) extra_root_certs: Vec, + /// Raw PEM bytes for each trust root added via `extra_root_cert`, kept + /// alongside the parsed `extra_root_certs` above. Threaded through to + /// `HttpConfig::with_parsed_root_certs` so transport-neutral callers + /// (`HttpConfig::resolve`) can hand PEM to non-reqwest TLS connectors + /// (e.g. `tokio-tungstenite`). + pub(crate) extra_root_certs_pem: Vec>, + pub(crate) server_vars: Vec, + /// Generator-supplied environment-variable overrides for spec-root + /// idempotency headers (parsed from `x-fern-idempotency-headers`). + /// Keyed by the entry's `name` (preferred) or `header` value; + /// `CliApp::build_doc` applies these to every idempotent operation's + /// synthetic header parameter so the `--` accepts the value + /// from the env var as a fallback. + idempotency_header_envs: HashMap, + /// Compile-time preset audiences. Operations whose + /// `x-fern-audiences` doesn't intersect this set are dropped from + /// the command tree before clap ever sees them. Empty (the default) + /// = no filter — every operation is included. + /// + /// Configured by the binary's `main.rs` via [`Self::audiences`]; not + /// exposed as a CLI flag, mirroring fern's intent that audience + /// selection is a build-time decision baked into the generated SDK + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`). + pub(crate) audiences: Vec, + /// Global parameters registered via [`global_parameter`](Self::global_parameter). + /// These are merged with (and take precedence over) parameters parsed + /// from the spec's `x-fern-global-parameters` extension, letting the + /// TypeScript codegen layer supply the authoritative set from the IR. + pub(crate) builder_global_parameters: Vec, +} + +#[allow(dead_code)] // Methods available for binding wrappers to delegate to. +impl CliApp { + /// Create a new CLI application with the given binary name. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + specs: Vec::new(), + title_override: None, + description_override: None, + auth_bindings: Vec::new(), + auth_strategy: AuthStrategy::Auto, + auth_layers: Vec::new(), + extra_root_certs: Vec::new(), + extra_root_certs_pem: Vec::new(), + server_vars: Vec::new(), + idempotency_header_envs: HashMap::new(), + audiences: Vec::new(), + builder_global_parameters: Vec::new(), + } + } + + /// Pin the CLI surface to operations tagged with one of the given + /// `x-fern-audiences` values. Operations without an + /// `x-fern-audiences` tag, or whose tags don't intersect this set, + /// are dropped from the command tree at build time — they don't + /// appear in `--help`, `--schema`, completions, or anywhere else. + /// + /// Multiple audiences union (OR): an operation tagged with *any* of + /// the listed audiences survives. Calling `.audiences([])` (or not + /// calling this at all) is a no-op — every operation is included. + /// + /// Audience selection is a compile-time decision baked into each + /// binary's `main.rs`, not a runtime flag. This mirrors fern's + /// importer semantics + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`), + /// where the audience filter physically removes operations from the + /// IR rather than hiding them at execution time. + /// + /// ```ignore + /// CliApp::new("my-public-api") + /// .spec(include_str!("openapi.yaml")) + /// .audiences(["public"]) + /// .run(); + /// ``` + pub fn audiences(mut self, audiences: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.audiences = audiences + .into_iter() + .map(Into::into) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + self + } + + /// Register a global parameter that surfaces as a top-level CLI flag + /// and is injected into outgoing requests at the configured wire location. + /// + /// This is the builder entry point emitted by the TypeScript codegen + /// layer (`detectGlobalParams.ts`) from `ir.globalParameters`. Parameters + /// registered here are merged with (and take precedence over) any + /// parameters the Rust parser finds in the raw spec's + /// `x-fern-global-parameters` extension — the IR is authoritative. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.yaml")) + /// .global_parameter(GlobalParameter { + /// name: "api-version".into(), + /// location: GlobalParameterLocation::Query, + /// target: "api-version".into(), + /// env: Some("API_VERSION".into()), + /// default: None, + /// optional: false, + /// apply: GlobalParameterApplyMode::Auto, + /// parameter_name: None, + /// docs: Some("The API version to use.".into()), + /// }) + /// .run(); + /// ``` + pub fn global_parameter(mut self, param: GlobalParameter) -> Self { + self.builder_global_parameters.push(param); + self + } + + /// Register an environment-variable fallback for a spec-root + /// idempotency header (declared via `x-fern-idempotency-headers`). + /// + /// `name` matches against the entry's `name` field first, then its + /// `header` field — whichever the generator finds most convenient at + /// the call site. When the user invokes an idempotent operation + /// without the corresponding `--`, the value is taken from the + /// named environment variable. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.yaml")) + /// .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") + /// .run(); + /// ``` + /// + /// This is the cli-sdk entry point referenced by FER-9852, where the + /// generator emits one call per parsed idempotency header. The + /// header itself is only sent on operations marked + /// `x-fern-idempotent: true`; non-idempotent operations are + /// unaffected. + pub fn idempotency_header_env(mut self, name: &str, env_var: &str) -> Self { + self.idempotency_header_envs.insert(name.to_string(), env_var.to_string()); + self + } + + /// Register a server-URL template variable (e.g. `{store_hash}`). + /// + /// Auto-generates a global `--` flag (with kebab-cased name) and + /// resolves the value at request time from, in order: + /// 1. The CLI flag + /// 2. The given env var (if any) + /// 3. The built-in default (if any) + /// 4. Otherwise, errors with a helpful message + /// + /// Used for multi-tenant APIs where every URL is parameterized — the + /// canonical example is BigCommerce's `{store_hash}`. Variables + /// referenced in `servers[].url` but not registered here remain literal + /// in the URL (and the request will fail at send time), so registering + /// them is effectively required. + pub fn server_var( + mut self, + name: &str, + env_var: Option<&str>, + default: Option<&str>, + description: Option<&str>, + ) -> Self { + self.server_vars.push(ServerVar { + name: name.to_string(), + env_var: env_var.map(str::to_string), + default: default.map(str::to_string), + description: description.map(str::to_string), + }); + self + } + + /// Add an OpenAPI spec YAML string. May be called multiple times; specs are flat-merged. + /// Typically used with `include_str!`. + pub fn spec(mut self, yaml: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: Vec::new(), + overlays: Vec::new(), + overrides: Vec::new(), + }); + self + } + + /// Add an OpenAPI spec with a Fern-style overrides file applied before parsing. + /// + /// The override YAML is deep-merged onto the spec: maps merge key-by-key + /// (override wins on leaf collisions), arrays replace wholesale, and + /// `null` values delete the corresponding key. This matches the Fern CLI's + /// `generators.yml` `overrides:` behavior. + /// + /// Use this to add `x-fern-sdk-group-name`, `x-fern-sdk-method-name`, or + /// any other spec-level patches without modifying the upstream spec. + pub fn spec_with_overrides(mut self, yaml: &str, overrides: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: Vec::new(), + overlays: Vec::new(), + overrides: vec![overrides.to_string()], + }); + self + } + + /// Add an OpenAPI spec whose resources are wrapped under `prefix`. Use + /// slashes to nest: `"v3/customers"` puts the spec's resources under + /// `v3.customers.*`. Multiple `spec_under` calls with the same path + /// merge into a shared namespace; inner-resource collisions error. + pub fn spec_under(mut self, prefix: &str, yaml: &str) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: split_prefix(prefix), + overlays: Vec::new(), + overrides: Vec::new(), + }); + self + } + + /// Like [`spec_under`](Self::spec_under), but with a Fern-style overrides + /// file deep-merged onto the spec before parsing. + pub fn spec_under_with_overrides( + mut self, + prefix: &str, + yaml: &str, + overrides: &str, + ) -> Self { + self.specs.push(SpecEntry { + yaml: yaml.to_string(), + prefix_path: split_prefix(prefix), + overlays: Vec::new(), + overrides: vec![overrides.to_string()], + }); + self + } + + /// Add multiple specs that all merge under the same `prefix` (flat). + /// Equivalent to repeated `spec_under` calls; inner-resource collisions + /// across the specs error at startup. + pub fn specs_under(mut self, prefix: &str, yamls: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + let path = split_prefix(prefix); + for yaml in yamls { + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path.clone(), + overlays: Vec::new(), + overrides: Vec::new(), + }); + } + self + } + + /// Add multiple specs under `prefix`, each given its own sub-namespace. + /// `specs_under_named("v3", [("customers", yaml1), ("orders", yaml2)])` + /// produces `v3.customers.*` and `v3.orders.*` — what `specs_under` + /// would flatten, this preserves per-spec scoping. Useful when specs + /// share cross-cutting tags (`Metafields`) that would otherwise collide + /// once flattened. + pub fn specs_under_named(mut self, prefix: &str, named: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + let parent = split_prefix(prefix); + for (sub, yaml) in named { + let mut path = parent.clone(); + path.extend(split_prefix(sub.as_ref())); + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path, + overlays: Vec::new(), + overrides: Vec::new(), + }); + } + self + } + + /// Like [`specs_under_named`](Self::specs_under_named), but each entry is + /// a `(name, yaml, overrides_yaml)` triple. The overrides file is + /// deep-merged onto the spec before parsing. + /// + /// ```ignore + /// CliApp::new("bigcommerce") + /// .specs_under_named_with_overrides("v3", [ + /// ("customers", + /// include_str!("specs/management/customers.v3.yml"), + /// include_str!("overrides/management/customers.v3.yml")), + /// ]) + /// ``` + pub fn specs_under_named_with_overrides( + mut self, + prefix: &str, + named: I, + ) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + O: AsRef, + { + let parent = split_prefix(prefix); + for (sub, yaml, overrides) in named { + let mut path = parent.clone(); + path.extend(split_prefix(sub.as_ref())); + self.specs.push(SpecEntry { + yaml: yaml.as_ref().to_string(), + prefix_path: path, + overlays: Vec::new(), + overrides: vec![overrides.as_ref().to_string()], + }); + } + self + } + + /// Add an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) + /// to the most recently added spec. Overlays are applied in order before + /// the spec is parsed into the internal representation. + /// + /// # Panics + /// + /// Panics if called before `.spec()` or `.spec_under()`. + /// + /// # Example + /// + /// ```rust,ignore + /// use fern_cli_sdk::openapi::CliApp; + /// + /// CliApp::new("my-api") + /// .spec(include_str!("openapi.yaml")) + /// .overlay(include_str!("overlay.yaml")) + /// .auth_scheme_env("bearerAuth", "MY_API_TOKEN") + /// .run() + /// ``` + pub fn overlay(mut self, overlay_yaml: &str) -> Self { + let entry = self + .specs + .last_mut() + .expect("overlay() called before spec(); add a spec first"); + entry.overlays.push(overlay_yaml.to_string()); + self + } + + /// Override the top-level --help title, regardless of what the spec(s) declare. + pub fn title(mut self, t: &str) -> Self { + self.title_override = Some(t.to_string()); + self + } + + /// Override the top-level --help description, regardless of what the spec(s) declare. + pub fn description(mut self, d: &str) -> Self { + self.description_override = Some(d.to_string()); + self + } + + /// Build the merged `RestDescription` from all registered specs. + pub(crate) fn build_doc(&self) -> Result { + if self.specs.is_empty() { + return Err(CliError::Discovery( + "No spec provided. Call .spec() on CliApp.".to_string(), + )); + } + + let mut merged: Option = None; + + for entry in &self.specs { + // 1. Apply overlays (RFC 7396 style) first. + let effective_yaml = crate::openapi::overlay::apply_overlays_to_spec( + &entry.yaml, + &entry.overlays, + )?; + + // 2. Apply Fern-style overrides (deep-merge) on top. + let spec_doc = if entry.overrides.is_empty() { + crate::openapi::load_openapi_spec(&effective_yaml, &self.name)? + } else { + let mut value: serde_yaml::Value = serde_yaml::from_str(&effective_yaml) + .map_err(|e| CliError::Discovery( + format!("Failed to parse OpenAPI spec: {e}"), + ))?; + for ovr in &entry.overrides { + let override_value: serde_yaml::Value = serde_yaml::from_str(ovr) + .map_err(|e| CliError::Discovery( + format!("Failed to parse overrides YAML: {e}"), + ))?; + value = crate::openapi::deep_merge_yaml(value, override_value); + } + crate::openapi::load_openapi_spec_from_value(value, &self.name)? + }; + + match merged { + None => { + let mut base = spec_doc; + let resources = std::mem::take(&mut base.resources); + base.resources = HashMap::new(); + merge_into_path(&mut base.resources, &entry.prefix_path, resources)?; + merged = Some(base); + } + Some(ref mut acc) => { + merge_into_path(&mut acc.resources, &entry.prefix_path, spec_doc.resources)?; + merge_schemas(&mut acc.schemas, spec_doc.schemas)?; + merge_security_schemes(&mut acc.security_schemes, spec_doc.security_schemes); + merge_sdk_variables(&mut acc.sdk_variables, spec_doc.sdk_variables); + merge_global_headers(&mut acc.global_headers, spec_doc.global_headers); + merge_global_parameters(&mut acc.global_parameters, spec_doc.global_parameters); + } + } + } + + let mut doc = merged.expect("at least one spec was processed"); + if let Some(ref t) = self.title_override { + doc.title = Some(t.clone()); + } + if let Some(ref d) = self.description_override { + doc.description = Some(d.clone()); + } + + // Merge builder-registered global parameters (from IR via + // TypeScript codegen). Builder params are authoritative — they + // replace any spec-parsed param with the same name. + if !self.builder_global_parameters.is_empty() { + // Remove spec-parsed params that the builder overrides by name. + let builder_names: std::collections::HashSet = + self.builder_global_parameters.iter().map(|p| p.name.clone()).collect(); + doc.global_parameters.retain(|p| !builder_names.contains(&p.name)); + // Prepend builder params (they take precedence in flag order), + // followed by the surviving spec-parsed params. + let mut merged = self.builder_global_parameters.clone(); + merged.append(&mut doc.global_parameters); + doc.global_parameters = merged; + } + + // Apply generator-supplied idempotency-header env overrides. + // The parser populates each idempotent operation's synthetic + // header MethodParameter with `env_var = entry.env` from the + // spec; this pass lets the generator override or supply that + // mapping post-hoc (FER-9852 builder API) so end users don't + // need to edit the spec to wire a new env var. + if !self.idempotency_header_envs.is_empty() { + apply_idempotency_header_envs(&mut doc, &self.idempotency_header_envs); + } + + Ok(doc) + } + + /// Return embedded spec(s) as a YAML string. + /// + /// - `raw == true` → byte-exact `SpecEntry.yaml` for each entry. + /// - `raw == false` → effective spec with overlays + overrides merged + /// (same pipeline as `build_doc`, but stops before parsing to + /// `RestDescription` to preserve full OpenAPI fidelity). + /// + /// Multi-spec binaries emit a YAML stream (`---`-delimited). + pub(crate) fn spec_yaml(&self, raw: bool) -> Result, crate::error::CliError> { + if self.specs.is_empty() { + return Ok(None); + } + + let mut documents: Vec = Vec::new(); + + for entry in &self.specs { + if raw { + documents.push(entry.yaml.clone()); + } else { + // Reproduce the overlay + override merge from build_doc, + // stopping before parsing to RestDescription. + let effective = crate::openapi::overlay::apply_overlays_to_spec( + &entry.yaml, + &entry.overlays, + )?; + + if entry.overrides.is_empty() { + documents.push(effective); + } else { + let mut value: serde_yaml::Value = + serde_yaml::from_str(&effective).map_err(|e| { + crate::error::CliError::Discovery(format!( + "Failed to parse OpenAPI spec: {e}" + )) + })?; + for ovr in &entry.overrides { + let override_value: serde_yaml::Value = + serde_yaml::from_str(ovr).map_err(|e| { + crate::error::CliError::Discovery(format!( + "Failed to parse overrides YAML: {e}" + )) + })?; + value = crate::openapi::deep_merge_yaml(value, override_value); + } + let merged = serde_yaml::to_string(&value).map_err(|e| { + crate::error::CliError::Discovery(format!( + "Failed to serialize merged spec: {e}" + )) + })?; + documents.push(merged); + } + } + } + + // Join as a YAML stream with document separators. + let yaml = if documents.len() == 1 { + documents.into_iter().next().unwrap() + } else { + let mut yaml = documents[0].clone(); + for doc in &documents[1..] { + if !yaml.ends_with('\n') { + yaml.push('\n'); + } + yaml.push_str("---\n"); + yaml.push_str(doc); + } + yaml + }; + + Ok(Some(yaml)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::from_env(env))`. + /// Covers the 80% case — most callers bind a scheme to one env var. + /// + /// ```ignore + /// CliApp::new("api") + /// .spec(include_str!("openapi.yaml")) + /// .auth_scheme_env("bearerAuth", "API_TOKEN") + /// .run(); + /// ``` + pub fn auth_scheme_env(self, scheme_name: &str, env_var: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::from_env(env_var)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::cli(arg_name))`. + /// Auto-registers a global `--` flag at run time. Accepts + /// either `"api-token"` or `"--api-token"`. + pub fn auth_scheme_cli(self, scheme_name: &str, arg_name: &str) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::cli(arg_name)) + } + + /// Shorthand for `auth_scheme(name, AuthCredentialSource::file(path))`. + /// `~` and `~/` are expanded against `$HOME`. + pub fn auth_scheme_file(self, scheme_name: &str, path: impl AsRef) -> Self { + self.auth_scheme(scheme_name, AuthCredentialSource::file(path)) + } + + /// Bind a credential source to a single-value auth scheme declared in the + /// spec's `components.securitySchemes` (bearer / apiKey / oauth2). + /// + /// `scheme_name` should match the spec key. The credential's resolved + /// value is sent according to the scheme's declared shape: + /// + /// | Scheme | Outgoing | + /// | -------------------- | ------------------------------------- | + /// | `http: bearer` | `Authorization: Bearer ` | + /// | `apiKey, in: header` | `: ` | + /// | `oauth2` | `Authorization: Bearer ` | + /// + /// When any operation in the spec declares per-endpoint `security:`, + /// the constructed provider is a [`RoutingAuthProvider`][rap] that picks + /// the right scheme per request. Otherwise it's a plain + /// [`AnyAuthProvider`][aap] that tries each binding in order. + /// + /// [rap]: crate::auth::RoutingAuthProvider + /// [aap]: crate::auth::AnyAuthProvider + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.auth_bindings + .push((scheme_name.to_string(), SchemeBinding::Token(source))); + self + } + + /// Bind separate username and password sources to an `http: basic` scheme. + /// Both must resolve for the provider to attach `Authorization: Basic + /// base64(user:pass)`; if either is missing the binding contributes no + /// credentials. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Basic { username, password }, + )); + self + } + + /// Plug in a fully-custom [`AuthProvider`][crate::auth::AuthProvider] for + /// a scheme name. Useful when the spec uses a scheme the SDK doesn't + /// model out-of-the-box (mTLS-derived headers, request signing, OAuth2 + /// client-credentials with token refresh, etc.). + /// + /// Accepts any concrete `AuthProvider` by value and wraps it in [`Arc`] + /// internally. For pre-built `Arc` values (sharing a + /// provider across multiple binders), use [`auth_provider_shared`]. + /// + /// [`auth_provider_shared`]: Self::auth_provider_shared + pub fn auth_provider

(self, scheme_name: &str, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_provider_shared(scheme_name, std::sync::Arc::new(provider)) + } + + /// Same as [`auth_provider`] but takes an already-built + /// [`DynAuthProvider`]. Use this when sharing one provider across + /// multiple bindings or storing custom providers in a registry. + /// + /// [`auth_provider`]: Self::auth_provider + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: DynAuthProvider, + ) -> Self { + self.auth_bindings.push(( + scheme_name.to_string(), + SchemeBinding::Custom(provider), + )); + self + } + + /// Register an *additive* auth layer: a provider whose headers are + /// attached on top of the composed primary auth whenever it has + /// credentials, regardless of the [`AuthStrategy`]. + /// + /// Unlike [`auth_scheme`](Self::auth_scheme) bound under + /// [`AuthStrategy::All`], a layer is strictly optional — it never makes + /// the primary auth mandatory and is silently skipped when its credential + /// is absent. Use it for a supplementary header that sits alongside real + /// auth and is only present in some environments. + /// + /// The motivating case is Lattice Sandboxes, which require an extra + /// `Anduril-Sandbox-Authorization: Bearer ` header in addition to + /// the normal bearer token — but only when developing against a sandbox: + /// + /// ```ignore + /// use fern_cli_sdk::auth::{AuthCredentialSource, HeaderAuthProvider}; + /// + /// CliApp::new("lattice") + /// .spec(include_str!("openapi.yaml")) + /// .auth_scheme_env("bearerHttpAuthentication", "ENVIRONMENT_TOKEN") + /// .auth_layer(HeaderAuthProvider::new( + /// "sandboxAuthorization", + /// "Anduril-Sandbox-Authorization", + /// AuthCredentialSource::from_env("SANDBOXES_TOKEN"), + /// true, // emit "Bearer " + /// )) + /// .run(); + /// ``` + pub fn auth_layer

(self, provider: P) -> Self + where + P: crate::auth::AuthProvider + 'static, + { + self.auth_layer_shared(std::sync::Arc::new(provider)) + } + + /// Same as [`auth_layer`](Self::auth_layer) but takes an already-built + /// [`DynAuthProvider`] (for sharing one provider across bindings). + pub fn auth_layer_shared(mut self, provider: DynAuthProvider) -> Self { + self.auth_layers.push(provider); + self + } + + /// Pin how the bound auth schemes compose into a single provider. + /// Defaults to [`AuthStrategy::Auto`], which derives the strategy from + /// the spec (Routing if any operation declares per-endpoint security, + /// otherwise Any). + /// + /// Generators that know their API's auth model statically can override + /// this — most importantly to express the [`All`][a] case (every + /// scheme on every request) which the spec doesn't always model. + /// + /// [a]: AuthStrategy::All + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.auth_strategy = strategy; + self + } + + /// Register an extra trust root that this CLI will accept on top of the + /// system's default roots. `pem` must be a PEM-encoded certificate (or + /// concatenated PEM bundle), typically loaded with `include_bytes!`. + /// + /// Useful for distributing a CLI inside an organization where every + /// machine should trust the company's internal CA out of the box, without + /// asking each user to set `_CA_BUNDLE`. + /// + /// ```ignore + /// # // ignored: needs a real PEM file at the include path. + /// CliApp::new("internal-tool") + /// .spec(include_str!("openapi.yaml")) + /// .extra_root_cert(include_bytes!("../certs/corp-ca.pem")) + /// .run() + /// ``` + /// + /// Panics if the bytes don't parse as PEM, or if the PEM contains no + /// certificates. Failing fast at startup is preferable to silently + /// shipping a CLI that ignores its bundled cert. + pub fn extra_root_cert(mut self, pem: &[u8]) -> Self { + // Share the validation path with `HttpConfig::with_extra_root_cert` + // so error wording stays in sync between the panicking builder API + // and the Result-returning lower-level API. + let certs = crate::http::parse_extra_root_cert(pem) + .unwrap_or_else(|e| panic!("CliApp::extra_root_cert: {e}")); + self.extra_root_certs.extend(certs); + self.extra_root_certs_pem.push(pem.to_vec()); + self + } + + /// Decorate a clap `Command` with server-variable flags, SDK-variable + /// flags, global-header flags, and the composed help footer. + /// Called from `OpenApiBinding::build_command()` to replicate what the + /// old `run_async` pipeline used to do inline. + pub(crate) fn decorate_command( + &self, + doc: &RestDescription, + mut cli: clap::Command, + ) -> clap::Command { + let auth_section = { + let base = crate::auth::render_auth_help_section(&self.auth_bindings); + let layer_rows: Vec<(String, Vec)> = self + .auth_layers + .iter() + .map(|p| (p.name().to_string(), p.credential_hints())) + .collect(); + let layers = crate::auth::render_auth_layers_help(&layer_rows); + match (base, layers) { + (Some(b), Some(l)) => Some(format!("{b}{l}")), + (Some(b), None) => Some(b), + // No primary bindings but layers exist — supply the heading. + (None, Some(l)) => Some(format!("Authentication:\n{l}")), + (None, None) => None, + } + }; + + // Server-variable flags (e.g. `--store-hash` for {store_hash}). + for var in &self.server_vars { + let kebab = crate::text::to_kebab_flag(&var.name); + let help_text = var + .description + .clone() + .unwrap_or_else(|| { + format!("Value for the {{{}}} URL template variable", var.name) + }); + let mut arg = clap::Arg::new(var.name.clone()) + .long(kebab) + .global(true) + .value_name(var.name.to_uppercase()) + .help(help_text); + if let Some(env) = &var.env_var { + arg = arg.env(env.clone()); + } + if let Some(default) = &var.default { + arg = arg.default_value(default.clone()); + } + cli = cli.arg(arg); + } + + // SDK-variable flags (`x-fern-sdk-variables`). + for var in &doc.sdk_variables { + let kebab = crate::text::to_kebab_flag(&var.name); + if sdk_variable_collides_with_builtin(&kebab) { + tracing::warn!( + variable = %var.name, + flag = %kebab, + "SDK variable flag collides with built-in; skipping" + ); + continue; + } + let screaming = crate::text::to_screaming_snake(&var.name); + let mut arg = clap::Arg::new(var.name.clone()) + .long(kebab) + .global(true) + .value_name(screaming.clone()) + .env(screaming); + if let Some(desc) = &var.description { + arg = arg.help(desc.clone()); + } + cli = cli.arg(arg); + } + + // Global-header flags (`x-fern-global-headers`). + use std::collections::HashSet; + let mut registered_kebabs: HashSet = HashSet::new(); + let mut global_header_help_pairs: Vec<(String, String)> = Vec::new(); + for h in &doc.global_headers { + let kebab = global_header_flag_name(h); + if global_header_flag_collides_with_builtin(&kebab) { + tracing::warn!( + header = %h.header, + flag = %kebab, + "Global-header flag collides with built-in; skipping" + ); + continue; + } + if !registered_kebabs.insert(kebab.clone()) { + tracing::warn!( + header = %h.header, + flag = %kebab, + "Duplicate global-header flag; skipping" + ); + continue; + } + let arg_id = global_header_arg_id(h); + let value_name = crate::text::to_screaming_snake(&kebab); + let mut help_lines: Vec = + vec![format!("Global header `{}` (sent on every request).", h.header)]; + if let Some(env) = &h.env { + help_lines.push(format!("Env: {env}.")); + } + if let Some(def) = &h.default { + help_lines.push(format!("Default: {def}.")); + } else if !h.optional { + help_lines.push("Required.".to_string()); + } + let help_text = help_lines.join(" "); + let prefix = format!("--{kebab} <{value_name}>"); + global_header_help_pairs.push((prefix, help_text.clone())); + let mut arg = clap::Arg::new(arg_id) + .long(kebab.clone()) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(env) = &h.env { + arg = arg.env(env.clone()); + } + if let Some(def) = &h.default { + arg = arg.default_value(def.clone()); + } + // A `global(true)` arg whose long name matches a per-operation + // parameter makes clap panic at build time ("Long option names + // must be unique for each argument"). When the flag collides + // with a parameter on some operation, register it per-command + // on the non-colliding leaves and let the per-op parameter win + // on the rest — rather than handing clap two args with the same + // long name (FER-11145). + if global_header_long_collides_with_param(&cli, &kebab) { + tracing::debug!( + header = %h.header, + flag = %kebab, + "Global-header flag collides with a per-operation parameter; \ + registering per-command so the per-op parameter wins" + ); + cli = register_global_header_on_nonconflicting_leaves(cli, &arg, &kebab); + } else { + cli = cli.arg(arg.global(true)); + } + } + + // Global-parameter flags (`x-fern-global-parameters`). + // Reuse `registered_kebabs` from global headers so cross-feature + // collisions (header + parameter producing the same flag) are + // detected rather than panicking clap. + let mut global_param_help_pairs: Vec<(String, String)> = Vec::new(); + for p in &doc.global_parameters { + let kebab = global_parameter_flag_name(p); + if global_parameter_flag_collides_with_builtin(&kebab) { + tracing::warn!( + name = %p.name, + flag = %kebab, + "Global-parameter flag collides with built-in; skipping" + ); + continue; + } + if !registered_kebabs.insert(kebab.clone()) { + tracing::warn!( + name = %p.name, + flag = %kebab, + "Global-parameter flag collides with an already-registered flag; skipping" + ); + continue; + } + let arg_id = global_parameter_arg_id(p); + let value_name = crate::text::to_screaming_snake(&kebab); + let location_label = match p.location { + crate::openapi::discovery::GlobalParameterLocation::Header => "header", + crate::openapi::discovery::GlobalParameterLocation::Query => "query", + crate::openapi::discovery::GlobalParameterLocation::Body => "body", + crate::openapi::discovery::GlobalParameterLocation::Path => "path", + }; + let mut help_lines: Vec = Vec::new(); + if let Some(ref docs) = p.docs { + help_lines.push(docs.clone()); + } else { + help_lines.push(format!( + "Global {location_label} parameter `{}`.", + p.target, + )); + } + if let Some(ref env) = p.env { + help_lines.push(format!("Env: {env}.")); + } + if let Some(ref def) = p.default { + help_lines.push(format!("Default: {def}.")); + } else if !p.optional { + help_lines.push("Required.".to_string()); + } + let help_text = help_lines.join(" "); + let prefix = format!("--{kebab} <{value_name}>"); + global_param_help_pairs.push((prefix, help_text.clone())); + let mut arg = clap::Arg::new(arg_id) + .long(kebab.clone()) + .hide(true) + .value_name(value_name) + .help(help_text); + if let Some(ref env) = p.env { + arg = arg.env(env.clone()); + } + if let Some(ref def) = p.default { + arg = arg.default_value(def.clone()); + } + if global_header_long_collides_with_param(&cli, &kebab) { + tracing::debug!( + name = %p.name, + flag = %kebab, + "Global-parameter flag collides with a per-operation parameter; \ + registering per-command so the per-op parameter wins" + ); + cli = register_global_header_on_nonconflicting_leaves(cli, &arg, &kebab); + } else { + cli = cli.arg(arg.global(true)); + } + } + + cli = cli.arg( + clap::Arg::new("debug") + .long("debug") + .action(clap::ArgAction::SetTrue) + .global(true) + .help("Dump HTTP request and response to stderr") + ); + + // Compose the root --help footer. Preserves the section order + // from the old run_async path: global headers → global params → auth → env vars. + let existing_after_help = cli.get_after_help().map(|s| s.to_string()); + let global_headers_section: Option = if global_header_help_pairs.is_empty() { + None + } else { + let prefix_width = global_header_help_pairs + .iter() + .map(|(p, _)| p.chars().count()) + .max() + .unwrap_or(0); + let rows: Vec = global_header_help_pairs + .iter() + .map(|(prefix, help)| { + let pad = prefix_width.saturating_sub(prefix.chars().count()); + format!(" {prefix}{:pad$} {help}", "", pad = pad) + }) + .collect(); + Some(format!("Global headers:\n{}", rows.join("\n"))) + }; + let global_params_section: Option = if global_param_help_pairs.is_empty() { + None + } else { + let prefix_width = global_param_help_pairs + .iter() + .map(|(p, _)| p.chars().count()) + .max() + .unwrap_or(0); + let rows: Vec = global_param_help_pairs + .iter() + .map(|(prefix, help)| { + let pad = prefix_width.saturating_sub(prefix.chars().count()); + format!(" {prefix}{:pad$} {help}", "", pad = pad) + }) + .collect(); + Some(format!("Global parameters:\n{}", rows.join("\n"))) + }; + let env_footer = super::commands::after_help_footer(&doc.name); + let base_footer = match existing_after_help { + Some(ref s) if !s.is_empty() => format!("{s}\n{env_footer}"), + _ => env_footer, + }; + cli = cli.after_help(compose_root_after_help_sections( + global_headers_section.as_deref(), + global_params_section.as_deref(), + auth_section.as_deref(), + &base_footer, + )); + + cli + } + + /// Resolve server variable values from clap matches and substitute + /// them into the doc's URLs. + pub(crate) fn apply_server_vars( + &self, + doc: &mut RestDescription, + matches: &clap::ArgMatches, + ) { + let mut subs = std::collections::HashMap::new(); + for var in &self.server_vars { + if let Some(val) = matches.get_one::(&var.name) { + subs.insert(var.name.clone(), val.clone()); + } + } + apply_server_var_substitutions(doc, &subs); + } + + /// Handle the `generate-skills` subcommand: validate the output + /// path, emit SKILL.md files, and report to stderr. + pub(crate) fn handle_generate_skills( + &self, + output_dir: Option<&str>, + doc: &RestDescription, + ) -> Result<(), CliError> { + let out_dir = output_dir.unwrap_or("skills").to_string(); + let resolved = crate::validate::validate_safe_output_dir(&out_dir)?; + + let files = + crate::openapi::skill_emitter::generate_skills(doc, &self.name, &self.auth_bindings); + + for (rel_path, content) in &files { + let full_path = resolved.join(rel_path); + if let Some(parent) = full_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CliError::Validation(format!( + "Failed to create directory {}: {e}", + parent.display() + )) + })?; + } + std::fs::write(&full_path, content).map_err(|e| { + CliError::Validation(format!( + "Failed to write {}: {e}", + full_path.display() + )) + })?; + } + + eprintln!( + "Wrote {} skill file(s) to {}/", + files.len(), + resolved.display() + ); + Ok(()) + } + + /// Construct the [`DynAuthProvider`] used for this run from the + /// registered bindings. With no bindings, returns a `NoAuthProvider` + /// — the CLI runs unauthenticated. + pub(crate) fn build_auth_provider(&self, doc: &RestDescription) -> DynAuthProvider { + let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); + let primary = crate::auth::build_provider_with_strategy( + &self.auth_bindings, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ); + self.wrap_auth_layers(primary) + } + + /// Layer any registered additive providers on top of the composed + /// primary. A no-op when no layers were registered, so the common case + /// pays nothing. + fn wrap_auth_layers(&self, primary: DynAuthProvider) -> DynAuthProvider { + if self.auth_layers.is_empty() { + primary + } else { + std::sync::Arc::new(crate::auth::LayeredAuthProvider::new( + primary, + self.auth_layers.clone(), + )) + } + } + + /// Build an auth provider from externally-finalized bindings. + /// Used by `OpenApiBinding::dispatch` after CLI-bound auth sources + /// have been resolved against the parsed clap matches. + pub(crate) fn build_auth_provider_from_finalized( + &self, + finalized: &[(String, crate::auth::SchemeBinding)], + doc: &RestDescription, + ) -> DynAuthProvider { + let has_per_endpoint = doc.resources.values().any(resource_has_per_endpoint_security); + let primary = crate::auth::build_provider_with_strategy( + finalized, + &doc.security_schemes, + self.auth_strategy, + has_per_endpoint, + ); + self.wrap_auth_layers(primary) + } +} + +/// One binding's worth of prepared state inside an [`AppContext`]. +/// +/// When a CLI registers multiple `OpenApiBinding`s, each contributes one +/// entry. Method lookups and execution are routed to the entry whose +/// spec owns the target method. +pub(crate) struct BindingEntry { + pub(crate) doc: RestDescription, + pub(crate) auth_provider: DynAuthProvider, + pub(crate) http_config: crate::http::HttpConfig, + pub(crate) global_headers: Vec<(String, String)>, + /// Pre-resolved global parameter values (from CLI flags / env / defaults). + /// The executor splits these by location at dispatch time. + pub(crate) global_params: Vec, +} + +/// Runtime context passed to custom command handlers. +/// +/// Provides access to the loaded API spec(s), the constructed auth +/// provider(s), and convenience methods for executing API methods. +/// +/// When multiple `OpenApiBinding`s are registered on the same `CliApp`, +/// `AppContext` holds all of their specs. Method lookups and +/// `execute()`/`invoke()` calls are automatically routed to the binding +/// that owns the target method — callers do not need to know which +/// binding a method came from. +pub struct AppContext { + entries: Vec, + /// Whether `--quiet` was passed on the command line. Threaded into + /// `OutputPipeline` by [`AppContext::execute`] so custom commands + /// honor the flag. + pub(crate) quiet: bool, + /// Base URL override resolved from `--base-url` / `{NAME}_BASE_URL`. + /// Threaded into `invoke()` so custom command handlers respect the + /// override the same way direct CLI dispatch does. + pub(crate) base_url_override: Option, + /// Whether `--debug` was passed on the command line. Stored for + /// use by the executor (DBO-1.3) to dump HTTP traffic to stderr. + pub(crate) debug: bool, +} + +impl AppContext { + pub(crate) fn new( + doc: RestDescription, + auth_provider: DynAuthProvider, + http_config: crate::http::HttpConfig, + global_headers: Vec<(String, String)>, + global_params: Vec, + ) -> Self { + Self { + entries: vec![BindingEntry { doc, auth_provider, http_config, global_headers, global_params }], + quiet: false, + base_url_override: None, + debug: false, + } + } + + pub(crate) fn with_quiet(mut self, quiet: bool) -> Self { + self.quiet = quiet; + self + } + + pub(crate) fn with_base_url_override(mut self, base_url_override: Option) -> Self { + self.base_url_override = base_url_override; + self + } + + pub(crate) fn with_debug(mut self, debug: bool) -> Self { + self.debug = debug; + self + } + + /// Add another binding's prepared state to this context. + pub(crate) fn add_entry(&mut self, entry: BindingEntry) { + self.entries.push(entry); + } + + /// Find which entry owns `method` by pointer identity. + fn entry_for_method(&self, method: &RestMethod) -> &BindingEntry { + for entry in &self.entries { + if resource_tree_contains_method(&entry.doc.resources, method) { + return entry; + } + } + &self.entries[0] + } + + /// Compute the per-op `extra_headers` slice from the pre-resolved + /// global headers, suppressing entries whose wire-name is also + /// supplied as a per-op `header` parameter via `params_json` + /// (per-op wins, mirroring the built-in command path). + /// + /// Required-header validation lives here rather than at + /// `AppContext` construction time because per-op overrides depend + /// on the specific operation being invoked: a required global + /// header with no resolved value is allowed when the operation + /// itself declares the same header as a per-op parameter (the + /// per-op value takes its place on the wire). This mirrors + /// `build_global_header_overrides` on the built-in command path so + /// custom-command handlers get the same validation error shape. + #[cfg(test)] + fn extra_headers_for( + &self, + method: &RestMethod, + params_json: Option<&str>, + ) -> Result, CliError> { + let entry = self.entry_for_method(method); + self.extra_headers_for_entry(entry, method, params_json) + } + + fn extra_headers_for_entry( + &self, + entry: &BindingEntry, + method: &RestMethod, + params_json: Option<&str>, + ) -> Result, CliError> { + let params: serde_json::Map = match params_json { + Some(s) if !s.trim().is_empty() => serde_json::from_str(s) + .map_err(|e| CliError::Validation(format!("Invalid params JSON: {e}")))?, + _ => serde_json::Map::new(), + }; + // HTTP header names are case-insensitive per RFC 7230 §3.2 — key + // the lookup table by lowercased wire-name so a custom-command + // handler that resolved `x-api-stage` still satisfies the spec's + // declared `X-API-Stage` global. + let resolved_by_wire: std::collections::HashMap = entry + .global_headers + .iter() + .map(|(n, v)| (n.to_ascii_lowercase(), v.as_str())) + .collect(); + finalize_global_header_overrides(&entry.doc.global_headers, method, ¶ms, |h| { + resolved_by_wire + .get(&h.header.to_ascii_lowercase()) + .map(|v| (*v).to_string()) + }) + } + + /// Compute the per-op `extra_global_params` slice from the + /// pre-resolved global parameters, applying the same apply-mode + /// filtering and per-op override suppression as + /// `build_global_parameter_overrides` on the built-in command path. + /// + /// Note: `entry.global_params` is already resolved (CLI flag > env > + /// default) in `binding.rs`; a global with no resolved value was + /// dropped there. Unlike the built-in path, this path does not raise + /// a "required global has no value" error — a custom command's own + /// handler owns request assembly, so the built-in required-param + /// enforcement is intentionally not duplicated here. + fn extra_global_params_for_entry( + &self, + entry: &BindingEntry, + method: &RestMethod, + params_json: Option<&str>, + ) -> Vec { + let params: serde_json::Map = match params_json { + Some(s) if !s.trim().is_empty() => serde_json::from_str(s).unwrap_or_default(), + _ => serde_json::Map::new(), + }; + + entry + .global_params + .iter() + .filter(|gp| { + // Look up the declaration by identity (`name`), not `target`: + // two params can share a target across locations. + let decl = entry.doc.global_parameters.iter().find(|d| d.name == gp.name); + if let Some(d) = decl { + if !global_param_apply_mode_admits(d, method) { + return false; + } + } + !per_op_param_overrides_global(¶ms, method, gp.location, &gp.target) + }) + .cloned() + .collect() + } + + /// Execute an API method by name, using the same executor as built-in + /// commands. Automatically routes to the binding that owns `method`. + pub fn execute( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + output_format: &formatter::OutputFormat, + ) -> Result<(), CliError> { + let entry = self.entry_for_method(method); + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: entry + .doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: entry + .doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + no_pager: true, + cli_name: String::new(), + }; + + let pipeline = formatter::OutputPipeline { + format: output_format.clone(), + color_mode: formatter::ColorMode::default(), + quiet: self.quiet, + query: None, + }; + let extra_headers = self.extra_headers_for_entry(entry, method, params_json)?; + let filtered_global_params = self.extra_global_params_for_entry(entry, method, params_json); + + // Custom commands dispatch from inside `run_async`, which is itself + // driven by a tokio runtime. Naively calling `block_on` from a sync + // handler panics ("Cannot start a runtime from within a runtime"). + // `block_in_place` parks the current worker so `block_on` is legal. + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(executor::execute_method( + &entry.doc, + method, + params_json, + body_json, + &entry.auth_provider, + None, + None, + None, + None, // no multipart for programmatic callers + false, + &pagination, + &pipeline, + false, + self.base_url_override.as_deref(), + &entry.http_config, + // TODO(mcp/programmatic): programmatic callers always + // honor `x-fern-sdk-return-value` (matches typed-SDK + // semantics). If/when an MCP-tool surface wraps this + // path and needs to expose `--no-extract` to its + // clients, plumb a flag through `AppContext::execute` + // rather than flipping this constant. + false, + // Programmatic callers always honor `x-fern-retries` + // — the debug-only `--no-retry` flag is intentionally + // a CLI-only surface. If/when an MCP-tool path needs + // to disable retries for stability/debugging, plumb + // a flag through `AppContext::execute` rather than + // flipping this constant. + false, + // Same trade-off for `--no-stream`: programmatic callers + // chaining streaming endpoints almost always want the + // events emitted as they arrive (stdout printing path); + // forcing buffered mode here would block the entire + // response in memory. The CLI surface keeps the + // streaming default; only the CLI front-end exposes the + // opt-in buffered toggle. + false, + self.debug, + &extra_headers, + &filtered_global_params, + )) + }) + .map(|_| ()) + } + + /// Invoke an API method and return the parsed JSON response. + /// + /// Like [`execute`](Self::execute) but captures the response instead of + /// printing it, and accepts a `binary_body_path` for operations with a + /// binary request body (e.g. AssemblyAI's `/v2/upload`). Designed for + /// custom commands that chain multiple API calls. + pub fn invoke( + &self, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + binary_body_path: Option<&str>, + ) -> Result { + let entry = self.entry_for_method(method); + let pagination = executor::PaginationConfig { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: entry + .doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: entry + .doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + no_pager: true, + cli_name: String::new(), + }; + + let extra_headers = self.extra_headers_for_entry(entry, method, params_json)?; + let filtered_global_params = self.extra_global_params_for_entry(entry, method, params_json); + // See note in `execute` — `block_in_place` is required because the + // handler runs inside the outer tokio runtime. + let value = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(executor::execute_method( + &entry.doc, + method, + params_json, + body_json, + &entry.auth_provider, + None, + None, + binary_body_path, + None, // no multipart for programmatic callers + false, + &pagination, + &formatter::OutputPipeline::default(), + true, // capture_output + self.base_url_override.as_deref(), + &entry.http_config, + // See TODO in `execute` above — same trade-off applies + // here: chained custom commands expect the + // spec-promised subvalue, not the raw envelope. + false, + // Programmatic callers always honor `x-fern-retries` + // (see note in `execute`). + false, + // `invoke` captures the response into a `serde_json::Value` + // for callers that chain multiple API calls. Stream-mode + // makes no sense here — the executor would have to invent + // an ordering decision (last event? array of events?) + // when the caller just wants a typed value back. Force + // buffered semantics so the captured value mirrors the + // unary-response shape callers already handle. + true, + // Programmatic callers (invoke) never use the debug dump — + // debug mode is a CLI-only surface. + false, + &extra_headers, + &filtered_global_params, + )) + })?; + + value.ok_or_else(|| { + CliError::Other(anyhow::anyhow!( + "API method returned no value (non-JSON or empty body)" + )) + }) + } + + /// Returns a reference to the loaded API spec. + /// + /// When multiple `OpenApiBinding`s are registered, this returns the + /// first binding's spec. Use [`find_method`](Self::find_method) to + /// search across all bindings. + pub fn spec(&self) -> &RestDescription { + &self.entries[0].doc + } + + /// Returns references to all loaded API specs. + /// + /// Each entry corresponds to one `OpenApiBinding` registered on the + /// `CliApp`. For single-binding CLIs the slice has exactly one element. + pub fn specs(&self) -> Vec<&RestDescription> { + self.entries.iter().map(|e| &e.doc).collect() + } + + /// Search all registered specs for a method at `resource.method_name`. + /// + /// This is the recommended way to look up methods in a multi-binding + /// CLI — it searches across all bindings and returns the first match. + pub fn find_method( + &self, + resource: &str, + method_name: &str, + ) -> Result<&RestMethod, CliError> { + for entry in &self.entries { + if let Some(r) = entry.doc.resources.get(resource) { + if let Some(m) = r.methods.get(method_name) { + return Ok(m); + } + } + } + Err(CliError::Validation(format!( + "no method '{method_name}' found in resource '{resource}' across {} binding(s)", + self.entries.len(), + ))) + } + + /// Returns a reference to the HTTP/TLS configuration for this CLI run. + /// + /// Holds the binary name (used to scope `_*` env vars) and any + /// compile-time trust roots. Non-reqwest transports — e.g. the + /// [`websocket`](crate::websocket) module — call + /// [`HttpConfig::resolve`](crate::http::HttpConfig::resolve) on this to + /// build their own TLS connectors while honoring the same env vars + /// users already configure for the HTTP path. + /// + /// Auth credentials are intentionally *not* exposed via `AppContext`: + /// transports needing a credential value take an + /// [`AuthCredentialSource`](crate::auth::AuthCredentialSource) directly + /// at the call site. See `docs/adr/0001-auth-provider-no-cred-extraction.md`. + pub fn http_config(&self) -> &crate::http::HttpConfig { + &self.entries[0].http_config + } + + /// Returns the base-URL override resolved from `--base-url` / + /// `{NAME}_BASE_URL`, or `None` if not set. + pub fn base_url_override(&self) -> Option<&str> { + self.base_url_override.as_deref() + } + + /// Build a [`CliExecutor`] wired to this context's HTTP/auth/retry stack. + /// + /// The executor is constructed from the first binding entry's config + /// and shared via `Arc` across all SDK client instances. This method + /// keeps `auth_provider` and `global_headers` internal to `AppContext`, + /// satisfying ADR-0001 (no credential exposure via public getters). + pub fn build_sdk_executor(&self) -> std::sync::Arc { + // `--debug` is threaded in so it works for custom commands too. Spec + // -declared credential header names come with it, so an + // `apiKey`-in-header value is redacted here exactly as on the OpenAPI + // path rather than printed in full. + let sensitive_headers: Vec = + crate::openapi::executor::spec_sensitive_header_names(&self.entries[0].doc) + .into_iter() + .map(str::to_string) + .collect(); + std::sync::Arc::new( + crate::sdk_executor::CliExecutor::new( + self.entries[0].http_config.clone(), + self.entries[0].auth_provider.clone(), + self.entries[0].global_headers.clone(), + self.base_url_override.as_ref().map(|s| s.to_string()), + ) + .with_debug(self.debug, sensitive_headers), + ) + } +} + + +/// Recursively check whether any method in the resource tree is the +/// same object (pointer-equal) as `target`. Used by +/// [`AppContext::entry_for_method`] to route `execute()`/`invoke()` +/// to the correct binding's auth and HTTP config. +fn resource_tree_contains_method( + resources: &std::collections::HashMap, + target: &RestMethod, +) -> bool { + for resource in resources.values() { + for m in resource.methods.values() { + if std::ptr::eq(m, target) { + return true; + } + } + if resource_tree_contains_method(&resource.resources, target) { + return true; + } + } + false +} + +/// Walk a resource (and its sub-resources) for any method that declares +/// `security_requirements`. Used by `build_auth_provider` to feed the +/// per-endpoint flag into `build_provider_with_strategy`. +fn resource_has_per_endpoint_security(resource: &RestResource) -> bool { + if resource + .methods + .values() + .any(|m| m.security_requirements.is_some()) + { + return true; + } + resource.resources.values().any(resource_has_per_endpoint_security) +} + +/// Recursively walks clap ArgMatches to find the leaf method and its matches. +pub fn resolve_method_from_matches<'a>( + doc: &'a RestDescription, + matches: &'a clap::ArgMatches, +) -> Result<(&'a RestMethod, &'a clap::ArgMatches), CliError> { + let mut path: Vec<&str> = Vec::new(); + let mut current_matches = matches; + + while let Some((sub_name, sub_matches)) = current_matches.subcommand() { + path.push(sub_name); + current_matches = sub_matches; + } + + if path.is_empty() { + return Err(CliError::Validation( + "No resource or method specified".to_string(), + )); + } + + let resource_name = path[0]; + let resource = doc + .resources + .get(resource_name) + .ok_or_else(|| CliError::Validation(format!("Resource '{resource_name}' not found")))?; + + let mut current_resource = resource; + + for &name in &path[1..path.len() - 1] { + if let Some(sub) = current_resource.resources.get(name) { + current_resource = sub; + } else { + return Err(CliError::Validation(format!( + "Sub-resource '{name}' not found" + ))); + } + } + + let method_name = path[path.len() - 1]; + + if let Some(method) = current_resource.methods.get(method_name) { + return Ok((method, current_matches)); + } + + Err(CliError::Validation(format!( + "Method '{method_name}' not found on resource. Available methods: {:?}", + current_resource.methods.keys().collect::>() + ))) +} + +/// Collect individual flag values into a params map. +/// Values from --params JSON override individual flags. +/// +/// When a parameter has a `default_value` from `x-fern-default` and +/// the user did not supply the flag, clap surfaces the default as a +/// stringified value. We detect this via `ArgMatches::value_source` +/// and substitute the originally-typed JSON so numbers and booleans +/// keep their wire type — strings pass through unchanged. +/// +/// Parameters whose only default comes from the OpenAPI standard +/// `default:` keyword (stored on `documentation_default_value`) do +/// **not** get a clap default, so `get_one` returns `None` and the +/// `let-else continue` below correctly omits them from the outgoing +/// request — the API server applies its own default. +pub(crate) fn collect_params_from_flags( + matched_args: &clap::ArgMatches, + method: &crate::openapi::discovery::RestMethod, + params_override: Option<&str>, +) -> Result, CliError> { + let mut params = serde_json::Map::new(); + + // Collect values from individual flags. Three extensions interact here: + // + // 1. `x-fern-sdk-variable`: variable-bound path params are NOT + // registered as per-op flags (see `commands::build_resource_command`); + // their value comes from the root-level global flag registered in + // `run_async` from `doc.sdk_variables`. clap propagates global args + // down to subcommand matches so we look them up by the variable + // name on the same `matched_args`. If the global is unset, defer + // the validation error until AFTER the `--params` JSON override is + // applied below — `--params` is documented as "overrides individual + // flags" and must be allowed to act as a fallback here too, + // mirroring how plain path params behave when their per-op flag is + // absent. + // + // 2. `x-fern-default`: when clap surfaced an `x-fern-default` value + // (i.e. the user omitted the flag and the parameter had a + // `default_value` populated by `x-fern-default`), use the + // originally-typed JSON value so numbers/booleans keep their + // wire type instead of arriving as strings. + // + // 3. `x-fern-enum`: for user-supplied values on (non-variable-bound) + // parameters that declare enum aliases, resolve the display + // alias back to the wire value so the executor only ever sees + // what the server expects. + // Whether the user passed a whole-body `--json`. Body-located parameters + // whose value came from a *default* (a `const` field, or `x-fern-default`) + // must not be collected in that case: the executor treats any body key in + // `params` as a per-field body flag and refuses to combine it with `--json`, + // so a defaulted field the user never typed made `--json` impossible to use + // — the conflicting flag could never be absent. An explicit `--json` is the + // user's whole-body intent, so it wins over defaults; a per-field flag the + // user actually typed still conflicts, as it should. + let body_json_supplied = matched_args + .try_get_one::("json") + .ok() + .flatten() + .is_some(); + let is_body_param = |param_def: &crate::openapi::discovery::MethodParameter| { + param_def.location.as_deref() == Some("body") + }; + + let mut missing_variable_bound: Vec<(String, String)> = Vec::new(); + for (param_name, param_def) in &method.parameters { + if let Some(var_name) = param_def.variable_reference.as_deref() { + match matched_args.get_one::(var_name) { + Some(value) => { + params.insert( + param_name.clone(), + serde_json::Value::String(value.clone()), + ); + } + None => { + missing_variable_bound.push((param_name.clone(), var_name.to_string())); + } + } + continue; + } + + // The clap arg ID may differ from the wire name when the wire + // name collides with a built-in flag. Use the same resolution + // function the command builder uses (FER-10430). + let arg_id = crate::openapi::commands::param_clap_arg_id(param_name); + + if param_def.repeated { + if matched_args.value_source(&arg_id) + == Some(clap::parser::ValueSource::DefaultValue) + && body_json_supplied + && is_body_param(param_def) + { + continue; + } + if let Some(values) = matched_args.get_many::(&arg_id) { + // A value that parses as a JSON array is spliced in element + // by element (`--to '["a","b"]'` ≡ `--to a --to b`); anything + // else — including non-array JSON like "123" — stays a + // literal string. + let mut arr: Vec = Vec::new(); + for v in values { + match serde_json::from_str(v) { + Ok(serde_json::Value::Array(elems)) => arr.extend(elems), + _ => arr.push(serde_json::Value::String(v.clone())), + } + } + // For oneOf [string, array] unions, a single scalar + // value stays a plain string — only multiple values (or an + // explicit JSON array) produce an array on the wire. + let value = if param_def.scalar_or_array && arr.len() == 1 { + arr.into_iter().next().unwrap() + } else { + serde_json::Value::Array(arr) + }; + params.insert(param_name.clone(), value); + } + continue; + } + + let Some(value) = matched_args.get_one::(&arg_id) else { + continue; + }; + let from_default = matched_args.value_source(&arg_id) + == Some(clap::parser::ValueSource::DefaultValue); + if from_default && body_json_supplied && is_body_param(param_def) { + continue; + } + let json_value = match (from_default, ¶m_def.default_value) { + (true, Some(typed)) => typed.clone(), + _ => { + // Null sentinel, gated to user-supplied input so a + // default-injected "null" string is never reinterpreted. + // See ADR-0003. + if param_def.nullable && value == "null" { + serde_json::Value::Null + } else if matches!( + param_def.param_type.as_deref(), + Some("object") | Some("array") + ) { + // For object- and array-typed params (e.g. deepObject / + // form / spaceDelimited / pipeDelimited query parameters, + // or simple-style header parameters), attempt JSON parsing + // so the style-aware serializer receives a `Value::Object` / + // `Value::Array` rather than a verbatim string. Falls back + // to the raw string when the value isn't valid JSON. + match serde_json::from_str::(value.as_str()) { + Ok(mut parsed) => { + // Resolve `@filename` references inside nested + // string values — mirrors Stainless's CLI shorthand + // semantics (e.g. `--profile '{"pic":"@abe.jpg"}'`). + // FER-10436. + executor::resolve_file_refs(&mut parsed)?; + parsed + } + Err(_) => serde_json::Value::String(value.clone()), + } + } else { + let wire = param_def + .resolve_enum_display_to_wire(value.as_str()) + .into_owned(); + serde_json::Value::String(wire) + } + } + }; + params.insert(param_name.clone(), json_value); + } + + // Override with --params JSON if provided (--params wins). + if let Some(json_str) = params_override { + let overrides: serde_json::Map = + serde_json::from_str(json_str) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))?; + for (key, value) in overrides { + params.insert(key, value); + } + } + + // Now that --params has had its say, check whether any variable-bound + // parameter is still unsupplied. Only then emit the validation error + // naming both the global CLI flag and its env-var fallback. + for (param_name, var_name) in missing_variable_bound { + if !params.contains_key(¶m_name) { + let kebab = crate::text::to_kebab_flag(&var_name); + let env = crate::text::to_screaming_snake(&var_name); + return Err(CliError::Validation(format!( + "Missing required SDK variable '{var_name}': provide --{kebab}, \ + set ${env}, or include it in --params" + ))); + } + } + + Ok(params) +} + +/// Collect multipart/form-data parts from CLI arg matches. Returns `None` +/// when the operation has no multipart fields. File-typed fields reject +/// control characters (matching `binary_body_path` validation) but allow +/// absolute paths since users may upload files from anywhere on disk. +pub(crate) fn collect_multipart_parts( + method: &RestMethod, + matches: &clap::ArgMatches, +) -> Result>, crate::error::CliError> { + if method.multipart_fields.is_empty() { + return Ok(None); + } + + let mut parts = Vec::new(); + for field in &method.multipart_fields { + // Skip fields whose kebab name collides with a builtin flag — the + // arg was never registered so any value would come from the builtin. + let kebab = crate::text::to_kebab_flag(&field.wire_name); + if crate::openapi::commands::BUILTIN_FLAG_NAMES.contains(&kebab.as_str()) { + continue; + } + + let value = matches + .try_get_one::(&field.wire_name) + .ok() + .flatten(); + let Some(value) = value else { + continue; + }; + + if field.is_file { + let raw = value.as_str(); + // `\@literal` — escape syntax for sending a literal `@`-prefixed + // value on a file-typed field (FER-10436). The value is sent as a + // plain text part; no file read is attempted, and the path-safety + // validators that normally guard file inputs are skipped because + // there is no path to validate. + if executor::is_escaped_literal(raw) { + let literal = executor::strip_or_escape_at(raw).into_owned(); + parts.push(executor::MultipartPart::Text { + name: field.wire_name.clone(), + value: literal, + content_type: field.content_type.clone(), + }); + continue; + } + // Validate the inner filesystem path — the same string the executor + // will eventually pass to `tokio::fs::read`. `parse_at_ref` strips + // the `@`, `@file://`, or `@data://` prefix so an adversarial + // `@file://evil\x00path` is rejected before disk I/O regardless of + // which encoding mode was requested (FER-10532). Stdin is only the + // `Auto`-mode `-` sentinel; an explicit-scheme `-` is a literal + // filename and still gets validated. + let (inner, mode) = match executor::parse_at_ref(raw) { + executor::AtRef::File { path, mode } => (path, mode), + executor::AtRef::Plain(s) => (std::borrow::Cow::Borrowed(s), executor::AtMode::Auto), + // `\@literal` was handled above; reachable only as a defensive + // fallback if the escape branch is ever skipped. + executor::AtRef::Escaped(_) => continue, + }; + let is_stdin = mode == executor::AtMode::Auto && inner.as_ref() == "-"; + if !is_stdin { + crate::output::reject_dangerous_chars( + inner.as_ref(), + &format!("--{}", crate::text::to_kebab_flag(&field.wire_name)), + )?; + } + parts.push(executor::MultipartPart::File { + name: field.wire_name.clone(), + path: raw.to_string(), + content_type: field.content_type.clone(), + }); + } else { + parts.push(executor::MultipartPart::Text { + name: field.wire_name.clone(), + value: value.clone(), + content_type: field.content_type.clone(), + }); + } + } + + if parts.is_empty() { + Ok(None) + } else { + Ok(Some(parts)) + } +} + +pub(crate) fn build_pagination_config( + matches: &clap::ArgMatches, + doc: &RestDescription, + cli_name: &str, +) -> executor::PaginationConfig { + executor::PaginationConfig { + page_all: matches.get_flag("page-all"), + page_limit: matches + .get_one::("page-limit") + .copied() + .unwrap_or(10), + page_delay_ms: matches + .get_one::("page-delay") + .copied() + .unwrap_or(100), + token_query_param: doc + .pagination_token_query_param + .clone() + .unwrap_or_else(|| "pageToken".to_string()), + token_response_path: doc + .pagination_token_response_path + .clone() + .unwrap_or_else(|| "nextPageToken".to_string()), + no_pager: matches.get_flag("no-pager"), + cli_name: cli_name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ------------------------------------------------------------------ + // x-fern-global-headers (FER-9864 P2) — registration helpers. + // ------------------------------------------------------------------ + + /// `global_header_flag_name` honors `name:` (kebab-cased) when set, + /// otherwise falls back to kebab-casing the wire header. This is + /// the same precedence the upstream Fern importer uses. + #[test] + fn test_global_header_flag_name_respects_name_field_then_header() { + let h_with_name = crate::openapi::discovery::GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_flag_name(&h_with_name), "api-stage"); + + let h_no_name = crate::openapi::discovery::GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_flag_name(&h_no_name), "x-tenant-id"); + } + + /// The clap arg ID for a global header must be namespaced so it + /// can't collide with any per-op parameter HashMap key. The wire + /// header is preserved verbatim so the executor's lookup against + /// `RestMethod.parameters` stays straightforward. + #[test] + fn test_global_header_arg_id_is_namespaced_by_wire_name() { + let h = crate::openapi::discovery::GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + assert_eq!(global_header_arg_id(&h), "__global_header::X-API-Stage"); + } + + /// `build_global_header_overrides` errors with a message naming the + /// flag, env var, and wire-header name when a required header has + /// no value source. Pins the human-facing error shape required by + /// the FER-9864 acceptance criteria ("required-without-value + /// fails"), at the level where the validation actually lives. + #[test] + fn test_build_global_header_overrides_errors_when_required_missing() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + use clap::Command; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIXTURE_API_STAGE".into()), + default: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); + + // Use a clap Command with NO defaults bound for the arg — + // simulating "user passed nothing, env unset, no default". + let cmd = Command::new("test").arg( + clap::Arg::new(global_header_arg_id(&doc.global_headers[0])) + .long(global_header_flag_name(&doc.global_headers[0])) + .global(true), + ); + let matches = cmd.try_get_matches_from(["test"]).unwrap(); + let params = serde_json::Map::new(); + + let err = build_global_header_overrides(&matches, &doc, &method, ¶ms).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("--api-stage"), + "error should name the CLI flag: {msg}" + ); + assert!( + msg.contains("FIXTURE_API_STAGE"), + "error should name the env var: {msg}" + ); + assert!( + msg.contains("X-API-Stage"), + "error should name the wire header: {msg}" + ); + } + + /// When the user supplies a per-op header parameter with the same + /// wire-name as a global header, the per-op value wins and the + /// global is dropped from the override list. Mirrors the upstream + /// Fern importer's per-op-wins behavior so operators get a single + /// override surface for collision cases. + #[test] + fn test_build_global_header_overrides_per_op_param_wins() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, + }; + use clap::Command; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: Some("production".into()), + }], + ..Default::default() + }; + + // Per-op method declares `X-API-Stage` as a header parameter. + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "X-API-Stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + + // Simulate clap matches with the global default applied. + let cmd = Command::new("test").arg( + clap::Arg::new(global_header_arg_id(&doc.global_headers[0])) + .long(global_header_flag_name(&doc.global_headers[0])) + .default_value("production") + .global(true), + ); + let matches = cmd.try_get_matches_from(["test"]).unwrap(); + + // The per-op `params` map contains a value for the same wire-name. + let mut params = serde_json::Map::new(); + params.insert("X-API-Stage".into(), serde_json::json!("canary")); + + let overrides = + build_global_header_overrides(&matches, &doc, &method, ¶ms).unwrap(); + assert!( + overrides.is_empty(), + "per-op param suppresses the global override, got: {overrides:?}", + ); + } + + /// `global_header_long_collides_with_param` inspects descendant + /// (subcommand) args only — a long shared with a per-op param is a + /// collision; a long that only exists as a root-level global is not. + #[test] + fn test_global_header_long_collides_with_param_scopes_to_subcommands() { + let cli = clap::Command::new("cli") + // A root-level global flag must NOT count as a collision. + .arg(clap::Arg::new("format").long("format").global(true)) + .subcommand( + clap::Command::new("products").subcommand( + clap::Command::new("retrieve") + .arg(clap::Arg::new("country").long("country")), + ), + ); + assert!( + global_header_long_collides_with_param(&cli, "country"), + "a per-op param on a leaf must register as a collision", + ); + assert!( + !global_header_long_collides_with_param(&cli, "format"), + "a root-level global flag must not register as a collision", + ); + assert!( + !global_header_long_collides_with_param(&cli, "language"), + "a long that exists nowhere in the tree is not a collision", + ); + } + + /// `register_global_header_on_nonconflicting_leaves` attaches the flag + /// to leaves that don't already declare it and leaves colliding leaves + /// untouched. + #[test] + fn test_register_global_header_skips_conflicting_leaf() { + let cli = clap::Command::new("cli").subcommand( + clap::Command::new("products") + .subcommand( + clap::Command::new("retrieve") + .arg(clap::Arg::new("country").long("country")), + ) + .subcommand(clap::Command::new("list")), + ); + let arg = clap::Arg::new("__global_header::X-Country").long("country"); + let cli = register_global_header_on_nonconflicting_leaves(cli, &arg, "country"); + let products = cli.find_subcommand("products").unwrap(); + let retrieve = products.find_subcommand("retrieve").unwrap(); + let list = products.find_subcommand("list").unwrap(); + // retrieve already declares --country → no global-header arg added. + assert!( + !retrieve + .get_arguments() + .any(|a| a.get_id().as_str() == "__global_header::X-Country"), + "colliding leaf must keep only its per-op param", + ); + // list has no --country → global-header arg attached. + assert!( + list.get_arguments() + .any(|a| a.get_id().as_str() == "__global_header::X-Country"), + "non-colliding leaf must receive the global-header flag", + ); + } + + /// Full regression for FER-11145: a global header whose flag name + /// matches a per-operation parameter must not make clap panic, and the + /// per-op param must win on the colliding command while the global + /// flag stays available on non-colliding siblings. + #[test] + fn test_decorate_command_global_header_param_collision_no_panic() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, RestResource, + }; + use std::collections::HashMap; + + // products.retrieve declares a per-op `country` query param. + let mut retrieve_params: HashMap = HashMap::new(); + retrieve_params.insert( + "country".into(), + MethodParameter { + location: Some("query".into()), + param_type: Some("string".into()), + ..Default::default() + }, + ); + let retrieve = RestMethod { + http_method: "GET".into(), + parameters: retrieve_params, + ..Default::default() + }; + // products.list carries no `country` param. + let list = RestMethod { + http_method: "GET".into(), + ..Default::default() + }; + let mut products = RestResource::default(); + products.methods.insert("retrieve".into(), retrieve); + products.methods.insert("list".into(), list); + let mut resources = HashMap::new(); + resources.insert("products".into(), products); + + let doc = RestDescription { + name: "channel3".into(), + resources, + global_headers: vec![GlobalHeader { + header: "X-Channel3-Country".into(), + name: Some("country".into()), + optional: true, + env: Some("CHANNEL3_COUNTRY".into()), + default: None, + }], + ..Default::default() + }; + + let app = CliApp::new("channel3"); + let cli = crate::openapi::commands::build_cli(&doc); + let cli = app.decorate_command(&doc, cli); + + // Before the fix, building this command panicked because + // `--country` was registered twice (global header + per-op param). + cli.clone().debug_assert(); + + // Colliding command: `--country` binds to the per-op param, and the + // global-header arg id is absent (per-op param wins). + let matches = cli + .clone() + .try_get_matches_from(["channel3", "products", "retrieve", "--country", "US"]) + .expect("retrieve should parse --country as the per-op param"); + let (_, products_m) = matches.subcommand().unwrap(); + let (_, retrieve_m) = products_m.subcommand().unwrap(); + assert_eq!( + retrieve_m.get_one::("country").map(String::as_str), + Some("US"), + ); + assert!( + resolve_global_header_value(retrieve_m, &doc.global_headers[0]).is_none(), + "global header must be dropped from the colliding command", + ); + + // Non-colliding sibling: `--country` binds to the global header. + let matches = cli + .try_get_matches_from(["channel3", "products", "list", "--country", "CA"]) + .expect("list should parse --country as the global-header flag"); + let (_, products_m) = matches.subcommand().unwrap(); + let (_, list_m) = products_m.subcommand().unwrap(); + assert_eq!( + resolve_global_header_value(list_m, &doc.global_headers[0]).as_deref(), + Some("CA"), + ); + } + + #[test] + fn test_sdk_variable_collides_with_builtin_flags() { + // Variables whose kebab form matches any built-in per-op flag + // must be flagged as colliding so the global registration site + // can skip them with a warning instead of letting clap panic. + // Cover the names that are most likely to be picked accidentally. + for builtin in ["params", "format", "dry-run", "base-url", "page-all", "output", "json", "debug"] { + assert!( + sdk_variable_collides_with_builtin(builtin), + "expected '{builtin}' to collide with a built-in flag", + ); + } + // Plain identifiers and innocuous variable names must NOT collide. + for ok in ["garden-id", "tenant-id", "page-token", "uuid", "client-id"] { + assert!( + !sdk_variable_collides_with_builtin(ok), + "expected '{ok}' NOT to collide with a built-in flag", + ); + } + } + + #[test] + fn test_cli_app_builder() { + let app = CliApp::new("test-cli") + .spec("openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\npaths: {}") + .auth_scheme_env("bearer", "TEST_TOKEN"); + + assert_eq!(app.name, "test-cli"); + assert_eq!(app.specs.len(), 1); + assert_eq!(app.auth_bindings.len(), 1); + assert_eq!(app.auth_bindings[0].0, "bearer"); + } + + #[test] + fn test_auth_scheme_records_token_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_scheme("bearerAuth", AuthCredentialSource::from_env("API_TOKEN")); + assert_eq!(app.auth_bindings.len(), 1); + assert_eq!(app.auth_bindings[0].0, "bearerAuth"); + match &app.auth_bindings[0].1 { + SchemeBinding::Token(_) => {} + other => panic!("expected Token, got {other:?}"), + } + } + + #[test] + fn test_auth_basic_scheme_records_basic_binding() { + let app = CliApp::new("t") + .spec("openapi: 3.0.0\ninfo:\n title: T\n version: '1.0'\npaths: {}") + .auth_basic_scheme( + "basic", + AuthCredentialSource::from_env("U"), + AuthCredentialSource::from_env("P"), + ); + assert!(matches!( + app.auth_bindings[0].1, + SchemeBinding::Basic { .. }, + )); + } + + #[test] + fn test_resolve_method_from_matches_basic() { + let mut resources = std::collections::HashMap::new(); + let mut files_res = crate::openapi::discovery::RestResource::default(); + files_res.methods.insert( + "list".to_string(), + crate::openapi::discovery::RestMethod { + id: Some("files.list".to_string()), + http_method: "GET".to_string(), + ..Default::default() + }, + ); + resources.insert("files".to_string(), files_res); + + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let cmd = clap::Command::new("cli") + .subcommand(clap::Command::new("files").subcommand(clap::Command::new("list"))); + + let matches = cmd.get_matches_from(vec!["cli", "files", "list"]); + let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(method.id.as_deref(), Some("files.list")); + } + + #[test] + fn test_resolve_method_from_matches_nested() { + let mut resources = std::collections::HashMap::new(); + let mut files_res = crate::openapi::discovery::RestResource::default(); + let mut permissions_res = crate::openapi::discovery::RestResource::default(); + permissions_res.methods.insert( + "get".to_string(), + crate::openapi::discovery::RestMethod { + id: Some("files.permissions.get".to_string()), + ..Default::default() + }, + ); + files_res + .resources + .insert("permissions".to_string(), permissions_res); + resources.insert("files".to_string(), files_res); + + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let cmd = + clap::Command::new("cli").subcommand(clap::Command::new("files").subcommand( + clap::Command::new("permissions").subcommand(clap::Command::new("get")), + )); + + let matches = cmd.get_matches_from(vec!["cli", "files", "permissions", "get"]); + let (method, _) = resolve_method_from_matches(&doc, &matches).unwrap(); + assert_eq!(method.id.as_deref(), Some("files.permissions.get")); + } + + #[test] + fn test_resolve_method_empty_path() { + let doc = RestDescription { + name: "test".to_string(), + ..Default::default() + }; + + let cmd = clap::Command::new("cli"); + let matches = cmd.get_matches_from(vec!["cli"]); + let result = resolve_method_from_matches(&doc, &matches); + assert!(result.is_err()); + } + + /// `AppContext::extra_headers_for` mirrors the built-in command + /// path: a required global header with no resolved value and no + /// per-op override fails with a validation error that names both + /// the CLI flag and the env var. This is the regression test for + /// the custom-command-handler path that previously dropped the + /// header silently. + #[test] + fn test_app_context_extra_headers_required_missing_errors() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIXTURE_API_STAGE".into()), + default: None, + }], + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + // Note: the custom-command path's filter_map silently + // dropped this required header. With the fix, + // extra_headers_for surfaces a validation error. + Vec::new(), + Vec::new(), + ); + let method = RestMethod::default(); + let err = ctx.extra_headers_for(&method, None).unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("--api-stage"), "should name flag: {msg}"); + assert!(msg.contains("FIXTURE_API_STAGE"), "should name env: {msg}"); + assert!(msg.contains("X-API-Stage"), "should name wire header: {msg}"); + } + + /// A required global header with no resolved value is permitted + /// when the operation itself declares a same-named header + /// parameter that the user supplied — the per-op value will be + /// sent on the wire in place of the global. Mirrors the built-in + /// command path's per-op-wins behavior. + #[test] + fn test_app_context_extra_headers_per_op_param_satisfies_required() { + use crate::openapi::discovery::{ + GlobalHeader, MethodParameter, RestDescription, RestMethod, + }; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }], + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + Vec::new(), + ); + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "X-API-Stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + let params_json = r#"{"X-API-Stage":"canary"}"#; + let headers = ctx + .extra_headers_for(&method, Some(params_json)) + .expect("per-op override should satisfy the required global header"); + assert!(headers.is_empty(), "per-op wins: globals dropped: {headers:?}"); + } + + /// An optional global header with no resolved value is silently + /// omitted (no error). Pins the negative case so a future + /// over-strict change to the required-header guard doesn't start + /// failing optional headers too. + #[test] + fn test_app_context_extra_headers_optional_missing_is_ok() { + use crate::openapi::discovery::{GlobalHeader, RestDescription, RestMethod}; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: true, + env: None, + default: None, + }], + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + Vec::new(), + ); + let method = RestMethod::default(); + let headers = ctx.extra_headers_for(&method, None).expect("optional ok"); + assert!(headers.is_empty(), "optional with no value: {headers:?}"); + } + + /// Multi-spec merge: when two specs declare the same wire-name in + /// `x-fern-global-headers`, the first write wins and the second is + /// silently dropped. Mirrors `merge_sdk_variables` and keeps the + /// resolved flag registry deterministic across spec ordering. + #[test] + fn test_merge_global_headers_first_write_wins() { + use crate::openapi::discovery::GlobalHeader; + + let mut acc = vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: Some("FIRST_STAGE".into()), + default: Some("production".into()), + }]; + let incoming = vec![ + // Same wire-name → must be dropped, preserving the first env / default. + GlobalHeader { + header: "X-API-Stage".into(), + name: Some("stage".into()), + optional: true, + env: Some("SECOND_STAGE".into()), + default: Some("staging".into()), + }, + // Distinct wire-name → must be appended. + GlobalHeader { + header: "X-Tenant-Id".into(), + name: None, + optional: true, + env: None, + default: None, + }, + ]; + merge_global_headers(&mut acc, incoming); + assert_eq!(acc.len(), 2, "got: {acc:?}"); + assert_eq!(acc[0].header, "X-API-Stage"); + assert_eq!(acc[0].env.as_deref(), Some("FIRST_STAGE")); + assert_eq!(acc[0].default.as_deref(), Some("production")); + assert!(!acc[0].optional); + assert_eq!(acc[1].header, "X-Tenant-Id"); + } + + /// Per-op-override match must be case-insensitive per RFC 7230 §3.2. + /// A spec that declares `X-API-Stage` globally and `x-api-stage` as a + /// header param on a single op should treat them as the same header + /// — the per-op value wins and the global is suppressed (rather than + /// both landing on the wire). + #[test] + fn test_per_op_header_param_override_is_case_insensitive() { + use crate::openapi::discovery::{GlobalHeader, MethodParameter, RestDescription, RestMethod}; + use std::collections::HashMap; + + let doc = RestDescription { + global_headers: vec![GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }], + ..Default::default() + }; + // Per-op param uses lowercase wire-name; case-insensitive lookup + // must still treat this as an override of the global. + let mut parameters: HashMap = HashMap::new(); + parameters.insert( + "x-api-stage".into(), + MethodParameter { + location: Some("header".into()), + ..Default::default() + }, + ); + let method = RestMethod { + parameters, + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + Vec::new(), + ); + // User supplied the per-op param under a third casing — the + // override should still kick in, satisfying the required check + // without a CLI flag / env value. + let headers = ctx + .extra_headers_for(&method, Some(r#"{"X-Api-Stage": "canary"}"#)) + .expect( + "per-op override should satisfy required-header check regardless of casing", + ); + assert!( + headers.is_empty(), + "global header must be suppressed when per-op param overrides it: {headers:?}", + ); + } + + /// `--api-stage ""` (or trimming-only whitespace) must NOT resolve + /// to `Some("")`. `resolve_global_header_value` trims and treats + /// empties as "no value supplied", so the required-header guard + /// fires instead of silently sending an empty `X-API-Stage:` header. + /// Pins the fix for the self-review finding noted in PR #45. + #[test] + fn test_resolve_global_header_value_filters_empty_and_whitespace() { + use crate::openapi::discovery::GlobalHeader; + + let h = GlobalHeader { + header: "X-API-Stage".into(), + name: Some("apiStage".into()), + optional: false, + env: None, + default: None, + }; + let cmd = clap::Command::new("t").arg( + clap::Arg::new(global_header_arg_id(&h)) + .long(global_header_flag_name(&h)), + ); + // Empty string flag value → None. + let m = cmd.clone().get_matches_from(["t", "--api-stage", ""]); + assert!( + resolve_global_header_value(&m, &h).is_none(), + "empty flag value must resolve to None", + ); + // Whitespace-only flag value → None. + let m = cmd.clone().get_matches_from(["t", "--api-stage", " "]); + assert!( + resolve_global_header_value(&m, &h).is_none(), + "whitespace-only flag value must resolve to None", + ); + // Normal value → Some(trimmed). + let m = cmd.get_matches_from(["t", "--api-stage", " canary "]); + assert_eq!( + resolve_global_header_value(&m, &h).as_deref(), + Some("canary"), + ); + } + + /// `compose_root_after_help_sections` joins present sections with + /// the footer and skips any `None` sections cleanly. Pins the + /// neither-auth-nor-global-headers regression target raised in PR + /// #45's self-review. + #[test] + fn test_compose_root_after_help_sections_skips_absent() { + let footer = "Standard env vars: …"; + let g = "Global headers:\n --api-stage …"; + let a = "Authentication:\n bearer …"; + + // All absent: only the footer. + assert_eq!( + compose_root_after_help_sections(None, None, None, footer), + footer, + "no global headers, no global params, no auth → only the footer is rendered", + ); + // Auth only: same as the pre-FER-9864 baseline. + assert_eq!( + compose_root_after_help_sections(None, None, Some(a), footer), + format!("{a}\n{footer}"), + ); + // Globals only: no auth section. + assert_eq!( + compose_root_after_help_sections(Some(g), None, None, footer), + format!("{g}\n{footer}"), + ); + // Both present: globals first, then auth, then footer. + assert_eq!( + compose_root_after_help_sections(Some(g), None, Some(a), footer), + format!("{g}\n{a}\n{footer}"), + ); + } + + #[test] + fn test_app_context_spec_accessor() { + let doc = RestDescription { + name: "test".to_string(), + ..Default::default() + }; + let ctx = AppContext::new( + doc, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + Vec::new(), + ); + assert_eq!(ctx.spec().name, "test"); + } + + #[test] + fn test_find_method_across_entries() { + use std::collections::HashMap; + + let mut res_a = HashMap::new(); + let mut methods_a = HashMap::new(); + methods_a.insert("upload".to_string(), RestMethod { + id: Some("files.upload".to_string()), + ..Default::default() + }); + res_a.insert("files".to_string(), RestResource { + methods: methods_a, + ..Default::default() + }); + + let mut res_b = HashMap::new(); + let mut methods_b = HashMap::new(); + methods_b.insert("list".to_string(), RestMethod { + id: Some("users.list".to_string()), + ..Default::default() + }); + res_b.insert("users".to_string(), RestResource { + methods: methods_b, + ..Default::default() + }); + + let doc_a = RestDescription { + name: "spec-a".to_string(), + resources: res_a, + ..Default::default() + }; + let doc_b = RestDescription { + name: "spec-b".to_string(), + resources: res_b, + ..Default::default() + }; + + let mut ctx = AppContext::new( + doc_a, + crate::auth::no_auth_provider(), + crate::http::HttpConfig::new("test").unwrap(), + Vec::new(), + Vec::new(), + ); + ctx.add_entry(BindingEntry { + doc: doc_b, + auth_provider: crate::auth::no_auth_provider(), + http_config: crate::http::HttpConfig::new("test").unwrap(), + global_headers: Vec::new(), + global_params: Vec::new(), + }); + + // find_method should find methods from either entry. + let m1 = ctx.find_method("files", "upload").expect("should find files.upload"); + assert_eq!(m1.id.as_deref(), Some("files.upload")); + + let m2 = ctx.find_method("users", "list").expect("should find users.list"); + assert_eq!(m2.id.as_deref(), Some("users.list")); + + // entry_for_method routes to the correct entry. + let entry1 = ctx.entry_for_method(m1); + assert_eq!(entry1.doc.name, "spec-a"); + + let entry2 = ctx.entry_for_method(m2); + assert_eq!(entry2.doc.name, "spec-b"); + + // Missing method returns error. + assert!(ctx.find_method("orders", "get").is_err()); + + // specs() returns both. + assert_eq!(ctx.specs().len(), 2); + } + + #[test] + fn test_collect_params_individual_flags() { + let mut params = std::collections::HashMap::new(); + params.insert( + "uuid".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("uuid").long("uuid")) + .arg(clap::Arg::new("params").long("params")); + + let matches = cmd.get_matches_from(vec!["test", "--uuid", "abc-123"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "abc-123"); + } + + /// Method with one `const`-style body field carrying a clap default, plus a + /// plain body field — the shape that made `--json` unusable. + fn method_with_defaulted_body_field() -> crate::openapi::discovery::RestMethod { + let mut params = std::collections::HashMap::new(); + params.insert( + "type".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + ..Default::default() + }, + ); + params.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + ..Default::default() + }, + ); + crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + } + } + + fn command_with_defaulted_type() -> clap::Command { + clap::Command::new("test") + .arg(clap::Arg::new("type").long("type").default_value("new")) + .arg(clap::Arg::new("name").long("name")) + .arg(clap::Arg::new("json").long("json")) + .arg(clap::Arg::new("params").long("params")) + } + + #[test] + fn test_defaulted_body_field_is_dropped_when_json_is_supplied() { + // The bug: `--type` carries a clap default (from a `const` field), so it + // was collected on every invocation and the executor rejected `--json` + // with "Cannot combine --json with per-field body flags (--type)" — for a + // flag the user never typed, and could not avoid. + let method = method_with_defaulted_body_field(); + let matches = command_with_defaulted_type() + .get_matches_from(vec!["test", "--json", r#"{"type":"new","name":"n"}"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert!( + !result.contains_key("type"), + "a default-sourced body field must not look like a per-field flag under --json, got: {result:?}" + ); + assert!(result.is_empty(), "no body params should be collected, got: {result:?}"); + } + + #[test] + fn test_defaulted_body_field_still_applies_without_json() { + // Without `--json` the default must still reach the body, or `const` + // fields would stop being sent on the flag-driven path. + let method = method_with_defaulted_body_field(); + let matches = command_with_defaulted_type().get_matches_from(vec!["test", "--name", "n"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("type").unwrap().as_str().unwrap(), "new"); + assert_eq!(result.get("name").unwrap().as_str().unwrap(), "n"); + } + + #[test] + fn test_explicit_body_flag_still_conflicts_with_json() { + // A per-field flag the user actually typed must still be collected, so + // the executor's mutual-exclusion check fires as intended. + let method = method_with_defaulted_body_field(); + let matches = command_with_defaulted_type().get_matches_from(vec![ + "test", + "--type", + "new", + "--json", + r#"{"name":"n"}"#, + ]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("type").unwrap().as_str().unwrap(), + "new", + "an explicitly-typed body flag must still conflict with --json" + ); + } + + #[test] + fn test_json_does_not_drop_defaulted_non_body_params() { + // The exemption is scoped to body params: a defaulted query/path/header + // value is not a "per-field body flag" and must survive `--json`. + let mut params = std::collections::HashMap::new(); + params.insert( + "page_size".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("page_size").long("page-size").default_value("25")) + .arg(clap::Arg::new("json").long("json")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--json", r#"{"a":1}"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("page_size").unwrap().as_str().unwrap(), + "25", + "a defaulted query param must still be sent alongside --json" + ); + } + + #[test] + fn test_collect_params_override_wins() { + let mut params = std::collections::HashMap::new(); + params.insert( + "uuid".to_string(), + crate::openapi::discovery::MethodParameter::default(), + ); + + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("uuid").long("uuid")) + .arg(clap::Arg::new("params").long("params")); + + let matches = cmd.get_matches_from(vec![ + "test", + "--uuid", + "from-flag", + "--params", + r#"{"uuid":"from-json"}"#, + ]); + let override_str = matches.get_one::("params").map(|s| s.as_str()); + let result = collect_params_from_flags(&matches, &method, override_str).unwrap(); + assert_eq!(result.get("uuid").unwrap().as_str().unwrap(), "from-json"); + } + + #[test] + fn test_collect_params_null_sentinel_on_nullable_param() { + // `--user-id null` on a nullable scalar body param must produce + // serde_json::Value::Null, not Value::String("null"). + let mut params = std::collections::HashMap::new(); + params.insert( + "userId".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("userId").long("user-id")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--user-id", "null"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("userId"), Some(&serde_json::Value::Null)); + } + + #[test] + fn test_collect_params_null_string_on_non_nullable_param_unchanged() { + // `--field null` on a non-nullable string param keeps current + // behavior: passes the four-character string through unchanged. + let mut params = std::collections::HashMap::new(); + params.insert( + "code".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: false, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("code").long("code")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--code", "null"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("code").and_then(|v| v.as_str()), + Some("null"), + "literal 'null' must pass through unchanged on non-nullable fields", + ); + } + + #[test] + fn test_collect_params_array_typed_param_parsed_from_json() { + // An array-typed param (e.g. a simple/array path param) carries a + // JSON-array string on the CLI; it must be parsed into a + // `Value::Array` so the path serializer can comma-join the elements + // instead of sending the verbatim `["a","b"]` string. + let mut params = std::collections::HashMap::new(); + params.insert( + "ids".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("ids").long("ids")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--ids", r#"["a","b"]"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("ids"), + Some(&serde_json::json!(["a", "b"])), + "array-typed param must be JSON-parsed into a Value::Array", + ); + } + + #[test] + fn test_collect_params_array_typed_param_invalid_json_falls_back_to_string() { + // Malformed JSON for an array-typed param keeps the verbatim string + // (no behavior change / no hard error for non-JSON input). + let mut params = std::collections::HashMap::new(); + params.insert( + "ids".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("ids").long("ids")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--ids", "not-json"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("ids").and_then(|v| v.as_str()), + Some("not-json"), + ); + } + + #[test] + fn test_collect_params_repeated_param_json_array_value_flattened() { + // A repeated string flag (array body props and string|array + // unions both lower to this shape) carrying a JSON-array literal must + // be parsed and its elements spliced in — not wrapped verbatim as a + // single array element. + let mut params = std::collections::HashMap::new(); + params.insert( + "to".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("to") + .long("to") + .action(clap::ArgAction::Append), + ) + .arg(clap::Arg::new("params").long("params")); + let matches = + cmd.get_matches_from(vec!["test", "--to", r#"["a@example.com","b@example.com"]"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("to"), + Some(&serde_json::json!(["a@example.com", "b@example.com"])), + "JSON-array value on a repeated flag must be parsed, not passed verbatim", + ); + } + + #[test] + fn test_collect_params_repeated_param_mixed_json_array_and_literal() { + // Occurrences can mix: a JSON-array literal flattens in place while + // plain values stay literal strings. + let mut params = std::collections::HashMap::new(); + params.insert( + "to".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("to") + .long("to") + .action(clap::ArgAction::Append), + ) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec![ + "test", + "--to", + r#"["a@example.com"]"#, + "--to", + "b@example.com", + ]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("to"), + Some(&serde_json::json!(["a@example.com", "b@example.com"])), + ); + } + + #[test] + fn test_collect_params_repeated_param_non_array_json_stays_literal() { + // Values that parse as non-array JSON ("123", "null", "{}") keep the + // old literal-string behavior — only arrays flatten. + let mut params = std::collections::HashMap::new(); + params.insert( + "tags".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("tags") + .long("tags") + .action(clap::ArgAction::Append), + ) + .arg(clap::Arg::new("params").long("params")); + let matches = + cmd.get_matches_from(vec!["test", "--tags", "123", "--tags", "null", "--tags", "{}"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("tags"), + Some(&serde_json::json!(["123", "null", "{}"])), + "non-array values must stay literal strings", + ); + } + + #[test] + fn test_collect_params_scalar_or_array_single_value_stays_scalar() { + // For oneOf [string, array] unions, a single value should be + // sent as a plain string, not wrapped in a length-1 array. + let mut params = std::collections::HashMap::new(); + params.insert( + "to".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + repeated: true, + scalar_or_array: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("to") + .long("to") + .action(clap::ArgAction::Append), + ) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--to", "a@example.com"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("to"), + Some(&serde_json::json!("a@example.com")), + "single value on scalar_or_array param must be a plain string", + ); + } + + #[test] + fn test_collect_params_scalar_or_array_multiple_values_become_array() { + // Multiple values on a scalar_or_array param should produce an array. + let mut params = std::collections::HashMap::new(); + params.insert( + "to".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + repeated: true, + scalar_or_array: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("to") + .long("to") + .action(clap::ArgAction::Append), + ) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec![ + "test", "--to", "a@example.com", "--to", "b@example.com", + ]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("to"), + Some(&serde_json::json!(["a@example.com", "b@example.com"])), + ); + } + + #[test] + fn test_collect_params_scalar_or_array_json_array_stays_array() { + // A JSON-array literal on a scalar_or_array param with >1 element + // produces an array. + let mut params = std::collections::HashMap::new(); + params.insert( + "to".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + repeated: true, + scalar_or_array: true, + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("to") + .long("to") + .action(clap::ArgAction::Append), + ) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec![ + "test", "--to", r#"["a@example.com","b@example.com"]"#, + ]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("to"), + Some(&serde_json::json!(["a@example.com", "b@example.com"])), + ); + } + + #[test] + fn test_collect_params_null_sentinel_does_not_apply_to_defaults() { + // When clap injects an `x-fern-default` value that happens to be the + // string "null", we must NOT convert it to Value::Null — the user + // didn't ask for null, the default did. The value_source check is + // load-bearing here. + let mut params = std::collections::HashMap::new(); + params.insert( + "userId".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: true, + default_value: Some(serde_json::Value::String("fallback".into())), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg( + clap::Arg::new("userId") + .long("user-id") + .default_value("fallback"), + ) + .arg(clap::Arg::new("params").long("params")); + // Omit the flag → clap fills "fallback" from default. + let matches = cmd.get_matches_from(vec!["test"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + // The typed default flows through, NOT a JSON null. + assert_eq!( + result.get("userId"), + Some(&serde_json::Value::String("fallback".into())), + ); + } + + #[test] + fn test_collect_params_empty_when_no_flags() { + let method = crate::openapi::discovery::RestMethod::default(); + let cmd = clap::Command::new("test").arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_collect_params_array_value_parsed_from_json() { + // An `array`-typed param carrying a JSON array string is parsed + // into a Value::Array so the style-aware serializer can explode / + // join it. Previously only `object` params were parsed. + let mut params = std::collections::HashMap::new(); + params.insert( + "tag".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("tag").long("tag")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--tag", r#"["a","b"]"#]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!(result.get("tag"), Some(&serde_json::json!(["a", "b"]))); + } + + #[test] + fn test_collect_params_array_invalid_json_falls_back_to_string() { + // Non-JSON input for an `array` param degrades to the raw string + // rather than erroring, mirroring the object branch. + let mut params = std::collections::HashMap::new(); + params.insert( + "tag".to_string(), + crate::openapi::discovery::MethodParameter { + param_type: Some("array".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = crate::openapi::discovery::RestMethod { + parameters: params, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("tag").long("tag")) + .arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test", "--tag", "not-json"]); + let result = collect_params_from_flags(&matches, &method, None).unwrap(); + assert_eq!( + result.get("tag").and_then(|v| v.as_str()), + Some("not-json"), + ); + } + + // ------------------------------------------------------------------ + // CliApp::idempotency_header_env — generator-side env-var wiring for + // FER-9852, implemented in cli-sdk for FER-9864 P1. Verifies the + // builder overlays env vars on every idempotent operation's + // synthetic header MethodParameter (and skips non-idempotent + // siblings). + // ------------------------------------------------------------------ + + const IDEMPOTENCY_SPEC: &str = r#" +openapi: 3.0.2 +info: + title: Idempotency Builder Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key +paths: + /payments: + get: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: list + operationId: payments_list + responses: + "200": + description: ok + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + + #[test] + fn test_idempotency_header_env_matches_by_name() { + // Generator wires env var by `name` field (kebab/snake form, + // not the wire header). Should land on the idempotent op's + // synthetic param. + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("idempotency_key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create.parameters.get("Idempotency-Key").unwrap(); + assert_eq!(p.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + } + + #[test] + fn test_idempotency_header_env_matches_by_header() { + // Falls back to the wire header name when `name` isn't matched. + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("Idempotency-Key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create.parameters.get("Idempotency-Key").unwrap(); + assert_eq!(p.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + } + + #[test] + fn test_idempotency_header_env_skips_non_idempotent_ops() { + let doc = CliApp::new("test") + .spec(IDEMPOTENCY_SPEC) + .idempotency_header_env("idempotency_key", "API_IDEMPOTENCY_KEY") + .build_doc() + .unwrap(); + let list = &doc.resources["payments"].methods["list"]; + assert!(!list.idempotent); + assert!( + !list.parameters.contains_key("Idempotency-Key"), + "non-idempotent op must have no idempotency-header param at all", + ); + } + + fn pagination_cmd() -> clap::Command { + clap::Command::new("test") + .arg( + clap::Arg::new("page-all") + .long("page-all") + .action(clap::ArgAction::SetTrue), + ) + .arg( + clap::Arg::new("page-limit") + .long("page-limit") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + clap::Arg::new("page-delay") + .long("page-delay") + .value_parser(clap::value_parser!(u64)), + ) + .arg( + clap::Arg::new("no-pager") + .long("no-pager") + .action(clap::ArgAction::SetTrue), + ) + } + + #[test] + fn test_build_pagination_config_defaults() { + let doc = RestDescription::default(); + let matches = pagination_cmd().get_matches_from(vec!["test"]); + let config = build_pagination_config(&matches, &doc, "test"); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + assert_eq!(config.token_query_param, "pageToken"); + assert_eq!(config.token_response_path, "nextPageToken"); + } + + #[test] + fn test_build_pagination_config_uses_doc_token_names() { + let doc = RestDescription { + pagination_token_query_param: Some("cursor".to_string()), + pagination_token_response_path: Some("meta.next_cursor".to_string()), + ..Default::default() + }; + let matches = pagination_cmd().get_matches_from(vec!["test"]); + let config = build_pagination_config(&matches, &doc, "test"); + assert_eq!(config.token_query_param, "cursor"); + assert_eq!(config.token_response_path, "meta.next_cursor"); + } + + #[test] + fn test_resolve_method_resource_not_found() { + let doc = RestDescription::default(); + let cmd = + clap::Command::new("cli").subcommand(clap::Command::new("unknown")); + let matches = cmd.get_matches_from(vec!["cli", "unknown"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Resource 'unknown' not found")); + } + + #[test] + fn test_resolve_method_method_not_found() { + let mut resources = std::collections::HashMap::new(); + resources.insert("files".to_string(), crate::openapi::discovery::RestResource::default()); + let doc = RestDescription { resources, ..Default::default() }; + + let cmd = clap::Command::new("cli") + .subcommand(clap::Command::new("files").subcommand(clap::Command::new("delete"))); + let matches = cmd.get_matches_from(vec!["cli", "files", "delete"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Method 'delete' not found")); + } + + #[test] + fn test_resolve_method_sub_resource_not_found() { + let mut resources = std::collections::HashMap::new(); + resources.insert("files".to_string(), crate::openapi::discovery::RestResource::default()); + let doc = RestDescription { resources, ..Default::default() }; + + let cmd = clap::Command::new("cli").subcommand( + clap::Command::new("files").subcommand( + clap::Command::new("permissions").subcommand(clap::Command::new("list")), + ), + ); + let matches = cmd.get_matches_from(vec!["cli", "files", "permissions", "list"]); + let err = resolve_method_from_matches(&doc, &matches).unwrap_err(); + assert!(err.to_string().contains("Sub-resource 'permissions' not found")); + } + + #[test] + fn test_collect_params_invalid_json_override() { + let method = crate::openapi::discovery::RestMethod::default(); + let cmd = clap::Command::new("test").arg(clap::Arg::new("params").long("params")); + let matches = cmd.get_matches_from(vec!["test"]); + let err = + collect_params_from_flags(&matches, &method, Some("{not valid json}")).unwrap_err(); + assert!(err.to_string().contains("Invalid --params JSON")); + } + + #[test] + fn test_multi_spec_flat_merge() { + // Two specs with non-overlapping resources should merge + let spec_a = r#" +openapi: "3.0.0" +info: + title: "API A" + version: "1.0" +servers: + - url: "https://api-a.example.com" +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let spec_b = r#" +openapi: "3.0.0" +info: + title: "API B" + version: "1.0" +servers: + - url: "https://api-b.example.com" +paths: + /orders: + get: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec(spec_a).spec(spec_b); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("users")); + assert!(doc.resources.contains_key("orders")); + } + + #[test] + fn test_multi_spec_collision_error() { + let spec = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec(spec).spec(spec); + let result = app.build_doc(); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("users"), "error should name the colliding key"); + } + + #[test] + fn test_title_description_override() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Original Title" + description: "Original description" + version: "1.0" +paths: {} +"#; + let app = CliApp::new("test") + .spec(spec) + .title("My Custom Title") + .description("My custom description"); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.title.as_deref(), Some("My Custom Title")); + assert_eq!(doc.description.as_deref(), Some("My custom description")); + } + + #[test] + fn test_spec_under_namespaces_resources() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Billing API" + version: "1.0" +servers: + - url: "https://billing.example.com" +paths: + /invoices: + get: + x-fern-sdk-group-name: ["invoices"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec_under("billing", spec); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("billing")); + let billing = doc.resources.get("billing").unwrap(); + assert!(billing.resources.contains_key("invoices")); + } + + #[test] + fn test_security_schemes_merge_across_multi_spec() { + // When two specs each declare `components.securitySchemes`, the + // merged doc should contain the union. Without merging, the second + // spec's schemes would silently disappear and the eventual + // RoutingAuthProvider registry would be missing entries — operations + // referencing those schemes would fall through to passthrough. + let spec_a = r#" +openapi: "3.0.0" +info: { title: A, version: "1.0" } +servers: [{ url: "https://a.example.com" }] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: B, version: "1.0" } +servers: [{ url: "https://b.example.com" }] +components: + securitySchemes: + apiKey: { type: apiKey, in: header, name: X-Api-Key } +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("multi").spec(spec_a).spec(spec_b).build_doc().unwrap(); + // Both schemes from both specs survive the merge. + assert!( + doc.security_schemes.contains_key("bearerAuth"), + "spec A's scheme missing: {:?}", + doc.security_schemes, + ); + assert!( + doc.security_schemes.contains_key("apiKey"), + "spec B's scheme missing: {:?}", + doc.security_schemes, + ); + } + + #[test] + fn test_merge_security_schemes_first_write_wins() { + use crate::openapi::discovery::SecurityScheme; + let mut acc = HashMap::new(); + acc.insert("bearerAuth".to_string(), SecurityScheme::HttpBearer); + let mut incoming = HashMap::new(); + // Same name, different shape — first write wins, like merge_schemas. + incoming.insert( + "bearerAuth".to_string(), + SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }, + ); + merge_security_schemes(&mut acc, incoming); + assert_eq!(acc["bearerAuth"], SecurityScheme::HttpBearer); + } + + #[test] + fn test_merge_schemas_first_write_wins_on_duplicate() { + // Multi-spec setups commonly share schema names (`ErrorResponse`, + // `Pagination`). Strict-error policy made BigCommerce-style use + // unworkable; first-write-wins lets specs share without manual + // de-duplication. + let mut acc = HashMap::new(); + acc.insert( + "ErrorResponse".to_string(), + crate::openapi::discovery::JsonSchema { + description: Some("first".to_string()), + ..Default::default() + }, + ); + let mut incoming = HashMap::new(); + incoming.insert( + "ErrorResponse".to_string(), + crate::openapi::discovery::JsonSchema { + description: Some("second".to_string()), + ..Default::default() + }, + ); + merge_schemas(&mut acc, incoming).expect("collision should not error"); + assert_eq!( + acc["ErrorResponse"].description.as_deref(), + Some("first"), + "first write should win" + ); + } + + #[test] + fn test_specs_under_batch_helper() { + // specs_under accepts an iterator of yamls and registers each under + // the same prefix. Sanity check it actually wires through. + let s1 = r#" +openapi: "3.0.0" +info: { title: A, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let s2 = r#" +openapi: "3.0.0" +info: { title: B, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").specs_under("ns", [s1, s2]); + let doc = app.build_doc().unwrap(); + let ns = &doc.resources["ns"]; + assert!(ns.resources.contains_key("alpha")); + assert!(ns.resources.contains_key("beta")); + } + + #[test] + fn test_spec_under_accepts_slash_delimited_path() { + // Slash splits into nested namespaces equivalent to specs_under_named. + let spec = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /widgets: + get: + x-fern-sdk-group-name: ["widgets"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = CliApp::new("t") + .spec_under("v3/products", spec) + .build_doc() + .unwrap(); + let v3 = doc.resources.get("v3").expect("v3 namespace"); + let products = v3.resources.get("products").expect("nested products"); + assert!(products.resources.contains_key("widgets")); + } + + #[test] + fn test_spec_under_merges_multiple_specs_into_same_prefix() { + // Two specs sharing a prefix should merge under it (not error). + // Prevents BigCommerce-style use cases where many v2 specs all need + // to live under a single `v2` namespace. + let spec_a = r#" +openapi: "3.0.0" +info: { title: "A", version: "1.0" } +servers: [{ url: "https://a.example.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "B", version: "1.0" } +servers: [{ url: "https://b.example.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("test") + .spec_under("v2", spec_a) + .spec_under("v2", spec_b); + let doc = app.build_doc().unwrap(); + let v2 = doc.resources.get("v2").expect("v2 prefix should exist"); + assert!(v2.resources.contains_key("alpha")); + assert!(v2.resources.contains_key("beta")); + } + + #[test] + fn test_spec_under_collides_on_inner_resource() { + // Two specs with the same inner resource under the same prefix collide. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://x.example.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let err = CliApp::new("test") + .spec_under("v2", spec) + .spec_under("v2", spec) + .build_doc() + .expect_err("inner-key collision should error"); + assert!(err.to_string().contains("things"), "error: {err}"); + } + + #[test] + fn test_spec_under_hoists_matching_top_level_resource() { + // When the namespace name matches a top-level resource in the spec, + // hoist that resource's methods into the namespace itself — so users + // type `customers get` instead of `customers customers get`. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } + /customers/{id}/addresses: + get: + tags: [Addresses] + operationId: getAddresses + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").spec_under("customers", spec); + let doc = app.build_doc().unwrap(); + let customers = doc.resources.get("customers").expect("namespace exists"); + // Methods from the spec's `customers` resource hoisted into namespace. + assert!(customers.methods.contains_key("get-customers")); + // Sibling top-level resources (`addresses`) become children of the namespace. + assert!(customers.resources.contains_key("addresses")); + // No double-nested `customers.customers` from the hoist. + assert!(!customers.resources.contains_key("customers")); + } + + #[test] + fn test_specs_under_named_creates_nested_namespaces() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: "A", version: "1.0" } +servers: [{ url: "https://a.example.com" }] +paths: + /alpha: + get: + x-fern-sdk-group-name: ["alpha"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let spec_b = r#" +openapi: "3.0.0" +info: { title: "B", version: "1.0" } +servers: [{ url: "https://b.example.com" }] +paths: + /beta: + get: + x-fern-sdk-group-name: ["beta"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("t").specs_under_named( + "v3", + [("alpha", spec_a), ("beta", spec_b)], + ); + let doc = app.build_doc().unwrap(); + let v3 = doc.resources.get("v3").expect("v3 namespace"); + // Both specs nested under their own sub-namespace inside v3 (with hoist). + assert!(v3.resources.contains_key("alpha")); + assert!(v3.resources.contains_key("beta")); + let alpha = &v3.resources["alpha"]; + assert!(alpha.methods.contains_key("list")); + } + + #[test] + fn test_substitute_url_vars_replaces_known_and_leaves_unknown() { + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "abc123".to_string()); + let url = "https://api.bigcommerce.com/stores/{store_hash}/v3/customers/{customer_id}"; + let out = substitute_url_vars(url, &subs); + // Known var substituted, unknown left literal so the failure mode is + // visible in dry-run output and downstream error messages. + assert_eq!( + out, + "https://api.bigcommerce.com/stores/abc123/v3/customers/{customer_id}" + ); + } + + #[test] + fn test_apply_server_var_substitutions_walks_nested_resources() { + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: [{ url: "https://api.example.com/stores/{store_hash}/v3" }] +paths: + /a/{id}/b: + get: + x-fern-sdk-group-name: ["a", "b"] + x-fern-sdk-method-name: get + responses: { "200": { description: ok } } +"#; + let mut doc = CliApp::new("t").spec(spec).build_doc().unwrap(); + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "xyz".to_string()); + apply_server_var_substitutions(&mut doc, &subs); + + let nested_method = doc.resources["a"].resources["b"].methods.get("get").unwrap(); + assert_eq!(nested_method.root_url, "https://api.example.com/stores/xyz/v3"); + } + + #[test] + fn test_apply_server_var_substitutions_walks_named_servers() { + // Spec combines `{store_hash}` URL template variables with + // `x-fern-server-name` named servers. The substitution pass + // must rewrite the named-server URLs too — otherwise + // `resolve_named_server_url` reads back an unsubstituted URL + // and the executor sends the request to a literal + // `{store_hash}` host. + let spec = r#" +openapi: "3.0.0" +info: { title: "T", version: "1.0" } +servers: + - url: "https://api.example.com/stores/{store_hash}/v3" + x-fern-server-name: Production + - url: "https://staging.example.com/stores/{store_hash}/v3" + x-fern-server-name: Staging +paths: + /uploads: + post: + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + servers: + - url: "https://upload.example.com/stores/{store_hash}/v3" + x-fern-server-name: Upload + responses: { "200": { description: ok } } +"#; + let mut doc = CliApp::new("t").spec(spec).build_doc().unwrap(); + let mut subs = HashMap::new(); + subs.insert("store_hash".to_string(), "abc123".to_string()); + apply_server_var_substitutions(&mut doc, &subs); + + // Top-level named servers are substituted. + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[0].url, "https://api.example.com/stores/abc123/v3"); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + assert_eq!(doc.servers[1].url, "https://staging.example.com/stores/abc123/v3"); + + // Per-operation `servers:` overrides are substituted too. + let create = doc.resources["uploads"].methods.get("create").unwrap(); + assert_eq!(create.servers.len(), 1); + assert_eq!(create.servers[0].name.as_deref(), Some("Upload")); + assert_eq!( + create.servers[0].url, + "https://upload.example.com/stores/abc123/v3", + ); + } + + #[test] + fn test_spec_under_root_url_on_methods() { + let spec = r#" +openapi: "3.0.0" +info: + title: "Billing API" + version: "1.0" +servers: + - url: "https://billing.example.com" +paths: + /invoices: + get: + x-fern-sdk-group-name: ["invoices"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let app = CliApp::new("test").spec_under("billing", spec); + let doc = app.build_doc().unwrap(); + let billing = doc.resources.get("billing").unwrap(); + let invoices = billing.resources.get("invoices").unwrap(); + let list = invoices.methods.get("list").unwrap(); + assert_eq!(list.root_url, "https://billing.example.com"); + } + + #[test] + fn test_per_method_root_url_set_by_openapi_parser() { + let spec = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +servers: + - url: "https://myapi.example.com" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let doc = crate::openapi::load_openapi_spec(spec, "myapi").unwrap(); + let method = doc.resources["things"].methods["list"].clone(); + assert_eq!(method.root_url, "https://myapi.example.com"); + } + + #[test] + fn test_overlay_applied_before_parsing() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + let app = CliApp::new("plant-api").spec(spec).overlay(overlay); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.description, Some("A plant management API".to_string())); + assert!(doc.resources.contains_key("plants")); + } + + #[test] + fn test_overlay_adds_fern_extensions() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + responses: + "200": + description: ok +"#; + // Overlay adds the fern extensions that were missing + let overlay = r#" +overlay: "1.0.0" +info: + title: Add fern extensions + version: "1.0" +actions: + - target: "$.paths['/plants'].get" + update: + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("plant-api").spec(spec).overlay(overlay); + let doc = app.build_doc().unwrap(); + assert!(doc.resources.contains_key("plants")); + assert!(doc.resources["plants"].methods.contains_key("list")); + } + + #[test] + fn test_multiple_overlays_on_same_spec() { + let spec = r#" +openapi: "3.0.0" +info: + title: Plant API + version: "1.0" +servers: + - url: https://api.plants.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "Plant API v1" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store" +"#; + let app = CliApp::new("plant-api") + .spec(spec) + .overlay(overlay1) + .overlay(overlay2); + let doc = app.build_doc().unwrap(); + assert_eq!(doc.description, Some("Plant API v1".to_string())); + assert!(doc.resources.contains_key("plants")); + } + + // ----------------------------------------------------------------------- + // Overrides integration tests + // ----------------------------------------------------------------------- + + #[test] + fn test_spec_with_overrides_applies_fern_extensions() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let customers = &doc.resources["customers"]; + assert!( + customers.methods.contains_key("list"), + "overrides should rename method to 'list', got: {:?}", + customers.methods.keys().collect::>() + ); + } + + #[test] + fn test_spec_under_with_overrides() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [Items] + operationId: getItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_under_with_overrides("v3", base, overrides); + let doc = app.build_doc().unwrap(); + let v3 = &doc.resources["v3"]; + let items = &v3.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides under prefix should rename method to 'list'" + ); + } + + #[test] + fn test_specs_under_named_with_overrides() { + let spec_a = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /orders: + get: + tags: [Orders] + operationId: getOrders + responses: { "200": { description: ok } } +"#; + let overrides_a = r#" +paths: + /orders: + get: + x-fern-sdk-group-name: [orders] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test") + .specs_under_named_with_overrides("v3", [("orders", spec_a, overrides_a)]); + let doc = app.build_doc().unwrap(); + let v3 = &doc.resources["v3"]; + // merge_into_path hoists: prefix "v3/orders" + group-name "orders" → v3 > orders > list + let orders = &v3.resources["orders"]; + assert!( + orders.methods.contains_key("list"), + "named overrides should rename method to 'list', got: {:?}", + orders.methods.keys().collect::>() + ); + } + + #[test] + fn test_spec_without_overrides_unchanged() { + // Verify that `.spec()` (no overrides) still works identically. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /pets: + get: + tags: [Pets] + operationId: getPets + responses: { "200": { description: ok } } +"#; + let app = CliApp::new("test").spec(yaml); + let doc = app.build_doc().unwrap(); + let pets = &doc.resources["pets"]; + assert!( + pets.methods.contains_key("get-pets"), + "without overrides, method name should come from operationId" + ); + } + + #[test] + fn test_overrides_null_removes_field() { + let base = r#" +openapi: "3.0.0" +info: + title: T + version: "1.0" + description: "Remove me" +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [Items] + operationId: listItems + summary: "Original summary" + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + summary: null + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let items = &doc.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides should rename method even when combined with null deletions" + ); + } + + /// Array-of-objects merge via overrides: servers array elements are merged + /// by index (Fern parity), so the override can patch just one field. + #[test] + fn test_overrides_array_of_objects_merged_by_index() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + description: Production + - url: "https://staging.example.com" + description: Staging +paths: + /items: + get: + tags: [Items] + operationId: listItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +servers: + - url: "https://api-patched.example.com" +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + // Server[0] should be merged (url patched, description preserved) + assert_eq!(doc.root_url, "https://api-patched.example.com"); + } + + /// Primitive array replacement via overrides: tags are primitives so the + /// override replaces rather than merging by index. + #[test] + fn test_overrides_primitive_array_replaced() { + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /items: + get: + tags: [OldTag] + operationId: listItems + responses: { "200": { description: ok } } +"#; + let overrides = r#" +paths: + /items: + get: + tags: [NewTag] + x-fern-sdk-group-name: [items] + x-fern-sdk-method-name: list +"#; + let app = CliApp::new("test").spec_with_overrides(base, overrides); + let doc = app.build_doc().unwrap(); + let items = &doc.resources["items"]; + assert!( + items.methods.contains_key("list"), + "overrides with replaced tags should still apply fern extensions" + ); + } + + /// Sequential overrides: two overrides applied in order. + #[test] + fn test_sequential_overrides_chain() { + use crate::openapi::parser::deep_merge_yaml; + + let base = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /a: + get: + tags: [A] + operationId: getA + responses: { "200": { description: ok } } + /b: + get: + tags: [B] + operationId: getB + responses: { "200": { description: ok } } +"#; + let ovr1 = r#" +paths: + /a: + get: + x-fern-sdk-group-name: [alpha] + x-fern-sdk-method-name: list +"#; + let ovr2 = r#" +paths: + /b: + get: + x-fern-sdk-group-name: [beta] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base).unwrap(); + let ovr1_val: serde_yaml::Value = serde_yaml::from_str(ovr1).unwrap(); + let ovr2_val: serde_yaml::Value = serde_yaml::from_str(ovr2).unwrap(); + let merged = deep_merge_yaml(deep_merge_yaml(base_val, ovr1_val), ovr2_val); + let doc = crate::openapi::parser::load_openapi_spec_from_value(merged, "t").unwrap(); + assert!(doc.resources["alpha"].methods.contains_key("list")); + assert!(doc.resources["beta"].methods.contains_key("list")); + } + + // ── Global Parameters ───────────────────────────────────────── + + #[test] + fn test_global_parameter_flag_name_uses_parameter_name_when_present() { + let p = crate::openapi::discovery::GlobalParameter { + name: "max-retries".into(), + parameter_name: Some("maxRetries".into()), + location: crate::openapi::discovery::GlobalParameterLocation::Header, + target: "X-Max-Retries".into(), + env: None, + default: None, + optional: false, + apply: crate::openapi::discovery::GlobalParameterApplyMode::Auto, + docs: None, + }; + assert_eq!(global_parameter_flag_name(&p), "max-retries"); + } + + #[test] + fn test_global_parameter_flag_name_falls_back_to_name() { + let p = crate::openapi::discovery::GlobalParameter { + name: "api-version".into(), + parameter_name: None, + location: crate::openapi::discovery::GlobalParameterLocation::Query, + target: "api-version".into(), + env: None, + default: None, + optional: false, + apply: crate::openapi::discovery::GlobalParameterApplyMode::Auto, + docs: None, + }; + assert_eq!(global_parameter_flag_name(&p), "api-version"); + } + + #[test] + fn test_global_parameter_arg_id_format() { + let p = crate::openapi::discovery::GlobalParameter { + name: "currency".into(), + parameter_name: None, + location: crate::openapi::discovery::GlobalParameterLocation::Body, + target: "currency".into(), + env: None, + default: None, + optional: false, + apply: crate::openapi::discovery::GlobalParameterApplyMode::Auto, + docs: None, + }; + assert_eq!(global_parameter_arg_id(&p), "global-param:currency"); + } + + #[test] + fn test_merge_global_parameters_first_write_wins() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + }; + + let mut acc = vec![GlobalParameter { + name: "currency".into(), + parameter_name: None, + location: GlobalParameterLocation::Query, + target: "currency".into(), + env: Some("FIRST_ENV".into()), + default: Some("USD".into()), + optional: false, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }]; + let incoming = vec![ + GlobalParameter { + name: "currency".into(), + parameter_name: None, + location: GlobalParameterLocation::Query, + target: "currency".into(), + env: Some("SECOND_ENV".into()), + default: Some("EUR".into()), + optional: true, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }, + GlobalParameter { + name: "region".into(), + parameter_name: None, + location: GlobalParameterLocation::Header, + target: "X-Region".into(), + env: None, + default: None, + optional: true, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }, + ]; + merge_global_parameters(&mut acc, incoming); + assert_eq!(acc.len(), 2, "duplicate dropped, new appended: {acc:?}"); + assert_eq!(acc[0].env.as_deref(), Some("FIRST_ENV")); + assert_eq!(acc[0].default.as_deref(), Some("USD")); + assert_eq!(acc[1].name, "region"); + } + + #[test] + fn test_build_global_parameter_overrides_auto_mode() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + RestDescription, RestMethod, + }; + + let doc = RestDescription { + global_parameters: vec![GlobalParameter { + name: "api-version".into(), + parameter_name: None, + location: GlobalParameterLocation::Query, + target: "api-version".into(), + env: None, + default: Some("2024-01-01".into()), + optional: false, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("global-param:api-version").long("api-version").default_value("2024-06-01")); + let matches = cmd.get_matches_from(vec!["test"]); + let params = serde_json::Map::new(); + let overrides = + build_global_parameter_overrides(&matches, &doc, &method, ¶ms) + .expect("auto mode should always apply"); + assert_eq!(overrides.len(), 1); + assert_eq!(overrides[0].target, "api-version"); + assert_eq!(overrides[0].value, "2024-06-01"); + assert!(matches!( + overrides[0].location, + GlobalParameterLocation::Query + )); + } + + #[test] + fn test_build_global_parameter_overrides_explicit_mode_included() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + RestDescription, RestMethod, + }; + + let doc = RestDescription { + global_parameters: vec![GlobalParameter { + name: "currency".into(), + parameter_name: None, + location: GlobalParameterLocation::Body, + target: "currency".into(), + env: None, + default: Some("USD".into()), + optional: false, + apply: GlobalParameterApplyMode::Explicit, + docs: None, + }], + ..Default::default() + }; + let method = RestMethod { + global_parameter_opt_ins: vec!["currency".to_string()], + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("global-param:currency").long("currency").default_value("USD")); + let matches = cmd.get_matches_from(vec!["test"]); + let params = serde_json::Map::new(); + let overrides = + build_global_parameter_overrides(&matches, &doc, &method, ¶ms) + .expect("explicit mode with opt-in should apply"); + assert_eq!(overrides.len(), 1); + assert_eq!(overrides[0].target, "currency"); + assert_eq!(overrides[0].value, "USD"); + } + + #[test] + fn test_build_global_parameter_overrides_explicit_mode_excluded() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + RestDescription, RestMethod, + }; + + let doc = RestDescription { + global_parameters: vec![GlobalParameter { + name: "currency".into(), + parameter_name: None, + location: GlobalParameterLocation::Body, + target: "currency".into(), + env: None, + default: Some("USD".into()), + optional: true, + apply: GlobalParameterApplyMode::Explicit, + docs: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); // no opt-ins + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("global-param:currency").long("currency").default_value("USD")); + let matches = cmd.get_matches_from(vec!["test"]); + let params = serde_json::Map::new(); + let overrides = + build_global_parameter_overrides(&matches, &doc, &method, ¶ms) + .expect("explicit mode without opt-in should skip"); + assert!( + overrides.is_empty(), + "explicit param not opted-in should not appear: {overrides:?}" + ); + } + + #[test] + fn test_build_global_parameter_overrides_per_op_override_suppresses_global() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + MethodParameter, RestDescription, RestMethod, + }; + + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "X-Api-Version".to_string(), + MethodParameter { + location: Some("header".to_string()), + ..Default::default() + }, + ); + let doc = RestDescription { + global_parameters: vec![GlobalParameter { + name: "api-version".into(), + parameter_name: None, + location: GlobalParameterLocation::Header, + target: "X-Api-Version".into(), + env: None, + default: Some("v1".into()), + optional: false, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }], + ..Default::default() + }; + let method = RestMethod { + parameters, + ..Default::default() + }; + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("global-param:api-version").long("api-version").default_value("v2")); + let matches = cmd.get_matches_from(vec!["test"]); + let mut params = serde_json::Map::new(); + params.insert("X-Api-Version".to_string(), serde_json::Value::String("v3-per-op".to_string())); + let overrides = build_global_parameter_overrides( + &matches, + &doc, + &method, + ¶ms, + ) + .expect("per-op override should suppress global"); + assert!( + overrides.is_empty(), + "per-op param wins, global should be suppressed: {overrides:?}" + ); + } + + #[test] + fn test_build_global_parameter_overrides_required_missing_errors() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + RestDescription, RestMethod, + }; + + let doc = RestDescription { + global_parameters: vec![GlobalParameter { + name: "api-key".into(), + parameter_name: None, + location: GlobalParameterLocation::Header, + target: "X-Api-Key".into(), + env: Some("API_KEY".into()), + default: None, + optional: false, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); + // Register the arg so clap recognizes it, but don't provide a value + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("global-param:api-key").long("api-key").required(false)); + let matches = cmd.get_matches_from(vec!["test"]); + let params = serde_json::Map::new(); + let err = + build_global_parameter_overrides(&matches, &doc, &method, ¶ms) + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("api-key"), "error names param: {msg}"); + } + + #[test] + fn test_build_global_parameter_overrides_optional_missing_skips() { + use crate::openapi::discovery::{ + GlobalParameter, GlobalParameterApplyMode, GlobalParameterLocation, + RestDescription, RestMethod, + }; + + let doc = RestDescription { + global_parameters: vec![GlobalParameter { + name: "trace-id".into(), + parameter_name: None, + location: GlobalParameterLocation::Header, + target: "X-Trace-Id".into(), + env: None, + default: None, + optional: true, + apply: GlobalParameterApplyMode::Auto, + docs: None, + }], + ..Default::default() + }; + let method = RestMethod::default(); + // Register the arg so clap recognizes it, but don't provide a value + let cmd = clap::Command::new("test") + .arg(clap::Arg::new("global-param:trace-id").long("trace-id").required(false)); + let matches = cmd.get_matches_from(vec!["test"]); + let params = serde_json::Map::new(); + let overrides = + build_global_parameter_overrides(&matches, &doc, &method, ¶ms) + .expect("optional missing should succeed"); + assert!( + overrides.is_empty(), + "optional with no value should be omitted: {overrides:?}" + ); + } +} diff --git a/src/openapi/binding.rs b/src/openapi/binding.rs new file mode 100644 index 0000000..723c8a7 --- /dev/null +++ b/src/openapi/binding.rs @@ -0,0 +1,1068 @@ +//! [`OpenApiBinding`] — adapts [`super::CliApp`] to the root +//! [`crate::binding::Binding`] trait so it can be composed into +//! a root-level [`crate::app::CliApp`]. + +use std::io::IsTerminal; +use std::sync::Arc; + +use crate::auth::{AuthCredentialSource, AuthStrategy, DynAuthProvider}; +use crate::binding::{Binding, BoxFuture, DispatchResult}; +use crate::error::CliError; +use crate::openapi::commands; +use crate::openapi::discovery::RestDescription; +use crate::openapi::executor; + +/// Prepared state computed once in `build_command()` and reused in +/// `dispatch()`. This avoids parsing the spec twice. +struct Prepared { + doc: RestDescription, + http_config: crate::http::HttpConfig, + auth_provider: DynAuthProvider, +} + +/// An OpenAPI binding that wraps [`super::CliApp`]'s internals and +/// exposes them through the [`Binding`] trait. +/// +/// ```rust,ignore +/// use fern_cli_sdk::app::CliApp; +/// use fern_cli_sdk::openapi::OpenApiBinding; +/// +/// fn main() { +/// CliApp::new("my-cli") +/// .binding( +/// OpenApiBinding::new() +/// .spec(include_str!("openapi.yaml")) +/// .auth_scheme_env("bearer", "MY_API_KEY"), +/// ) +/// .run() +/// } +/// ``` +#[must_use] +pub struct OpenApiBinding { + inner: super::CliApp, + /// Lazily computed on first `build_command()`, then reused in + /// `dispatch()`. `Arc` so we can clone it out of the lock without + /// holding across await. + prepared: std::sync::Mutex>>, + /// Optional namespace prefix. When set, all spec-derived subcommands + /// are nested under `Command::new(namespace)` in the clap tree and + /// the dispatch / schema paths strip the prefix before resolving + /// against the `RestDescription`. + command_namespace: Option, +} + +impl Default for OpenApiBinding { + fn default() -> Self { + Self { + inner: super::CliApp::new(""), + prepared: std::sync::Mutex::new(None), + command_namespace: None, + } + } +} + +impl OpenApiBinding { + /// Create a new OpenAPI binding. The CLI name is set automatically + /// by `CliApp::binding()` — no need to pass it here. + pub fn new() -> Self { + Self::default() + } + + /// Set the OpenAPI spec YAML string. + pub fn spec(mut self, yaml: &str) -> Self { + self.inner = self.inner.spec(yaml); + self + } + + /// Set a spec YAML with Fern-style overrides. + pub fn spec_with_overrides(mut self, yaml: &str, overrides: &str) -> Self { + self.inner = self.inner.spec_with_overrides(yaml, overrides); + self + } + + /// Set a spec under a prefix path. + pub fn spec_under(mut self, prefix: &str, yaml: &str) -> Self { + self.inner = self.inner.spec_under(prefix, yaml); + self + } + + /// Set multiple specs under a prefix. + pub fn specs_under(mut self, prefix: &str, yamls: I) -> Self + where + I: IntoIterator, + S: AsRef, + { + self.inner = self.inner.specs_under(prefix, yamls); + self + } + + /// Bind a credential source to a named auth scheme (env var shorthand). + pub fn auth_scheme_env(mut self, scheme_name: &str, env_var: &str) -> Self { + self.inner = self.inner.auth_scheme_env(scheme_name, env_var); + self + } + + /// Bind a credential source to a named auth scheme. + pub fn auth_scheme(mut self, scheme_name: &str, source: AuthCredentialSource) -> Self { + self.inner = self.inner.auth_scheme(scheme_name, source); + self + } + + /// Add multiple specs under `prefix`, each in its own sub-namespace. + pub fn specs_under_named(mut self, prefix: &str, named: I) -> Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + self.inner = self.inner.specs_under_named(prefix, named); + self + } + + /// Bind a custom auth provider to a named scheme. + pub fn auth_provider( + mut self, + scheme_name: &str, + provider: impl crate::auth::provider::AuthProvider + 'static, + ) -> Self { + self.inner = self.inner.auth_provider(scheme_name, provider); + self + } + + /// Bind a pre-built shared auth provider to a named scheme. + pub fn auth_provider_shared( + mut self, + scheme_name: &str, + provider: crate::auth::DynAuthProvider, + ) -> Self { + self.inner = self.inner.auth_provider_shared(scheme_name, provider); + self + } + + /// Pin how multiple auth schemes compose. See [`AuthStrategy`]. + pub fn auth_strategy(mut self, strategy: AuthStrategy) -> Self { + self.inner = self.inner.auth_strategy(strategy); + self + } + + /// Register an additive auth layer applied on top of the primary auth + /// whenever its credential is present. See [`CliApp::auth_layer`]. + /// + /// [`CliApp::auth_layer`]: crate::openapi::app::CliApp::auth_layer + pub fn auth_layer( + mut self, + provider: impl crate::auth::provider::AuthProvider + 'static, + ) -> Self { + self.inner = self.inner.auth_layer(provider); + self + } + + /// Register a pre-built shared additive auth layer. + /// See [`CliApp::auth_layer_shared`]. + /// + /// [`CliApp::auth_layer_shared`]: crate::openapi::app::CliApp::auth_layer_shared + pub fn auth_layer_shared(mut self, provider: crate::auth::DynAuthProvider) -> Self { + self.inner = self.inner.auth_layer_shared(provider); + self + } + + /// Bind HTTP Basic auth for the named scheme. + pub fn auth_basic_scheme( + mut self, + scheme_name: &str, + username: AuthCredentialSource, + password: AuthCredentialSource, + ) -> Self { + self.inner = self.inner.auth_basic_scheme(scheme_name, username, password); + self + } + + /// Register a server variable for URL template substitution. + pub fn server_var( + mut self, + name: &str, + env_var: Option<&str>, + default: Option<&str>, + description: Option<&str>, + ) -> Self { + self.inner = self.inner.server_var(name, env_var, default, description); + self + } + + /// Apply an overlay. + pub fn overlay(mut self, overlay_yaml: &str) -> Self { + self.inner = self.inner.overlay(overlay_yaml); + self + } + + /// Set compile-time audiences. + pub fn audiences(mut self, audiences: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.inner = self.inner.audiences(audiences); + self + } + + /// Register a global parameter that surfaces as a top-level CLI flag + /// and is injected into outgoing requests at the configured wire + /// location. Emitted by the TypeScript codegen layer + /// (`detectGlobalParams.ts`) from `ir.globalParameters`; delegates to + /// [`super::CliApp::global_parameter`], which merges these (with + /// precedence) over any params parsed from the spec's + /// `x-fern-global-parameters` extension. + pub fn global_parameter(mut self, param: crate::openapi::discovery::GlobalParameter) -> Self { + self.inner = self.inner.global_parameter(param); + self + } + + /// Mount all spec-derived subcommands under a namespace prefix. + /// + /// Without a namespace the generated commands are top-level: + /// `cli users list`, `cli files get`, etc. + /// + /// With `.command_namespace("api")` they move one level down: + /// `cli api users list`, `cli api files get`, etc. + /// + /// The namespace node sits beside any later-grafted custom commands + /// (`command_under(&["recipes"], …)` stays at root). `--help` and + /// `--schema` reflect the nesting automatically. + pub fn command_namespace(mut self, namespace: impl Into) -> Self { + let ns = namespace.into(); + // Guard against names that collide with framework-owned + // subcommands grafted by `CliApp::run`. Using one of these + // would cause the namespace node to be silently dropped. + const RESERVED: &[&str] = &["completion", "man", "auth"]; + assert!( + !RESERVED.contains(&ns.as_str()), + "command_namespace({ns:?}) collides with a reserved framework subcommand; \ + choose a different name", + ); + self.command_namespace = Some(ns); + self + } + + /// Prepare the binding state (idempotent; only runs once). + /// Returns an `Arc` clone so the caller doesn't hold the lock. + fn ensure_prepared(&self) -> Result, CliError> { + let mut guard = self.prepared.lock().unwrap(); + if let Some(ref arc) = *guard { + return Ok(Arc::clone(arc)); + } + + let mut doc = self.inner.build_doc()?; + commands::filter_doc_by_audiences(&mut doc, &self.inner.audiences); + + let http_config = crate::http::HttpConfig::new(&self.inner.name)? + .with_parsed_root_certs( + self.inner.extra_root_certs.iter().cloned(), + self.inner.extra_root_certs_pem.iter().cloned(), + ); + let auth_provider = self.inner.build_auth_provider(&doc); + + let arc = Arc::new(Prepared { + doc, + http_config, + auth_provider, + }); + *guard = Some(Arc::clone(&arc)); + Ok(arc) + } + + /// Build a [`BindingEntry`](super::app::BindingEntry) from this + /// binding's prepared state and the current CLI matches. + fn build_binding_entry( + &self, + matches: &clap::ArgMatches, + ) -> Result { + let prepared = self.ensure_prepared()?; + let mut doc_owned; + let doc = if self.inner.server_vars.is_empty() { + &prepared.doc + } else { + doc_owned = prepared.doc.clone(); + self.inner.apply_server_vars(&mut doc_owned, matches); + &doc_owned + }; + + // Finalize CLI-arg-bound auth sources against parsed matches, + // mirroring dispatch() so custom command handlers get working auth. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized, doc) + }; + + let global_headers: Vec<(String, String)> = doc + .global_headers + .iter() + .filter_map(|h| { + let val = super::app::resolve_global_header_value(matches, h)?; + Some((h.header.clone(), val)) + }) + .collect(); + let global_params: Vec = doc + .global_parameters + .iter() + .filter_map(|p| { + let val = super::app::resolve_global_parameter_value(matches, p)?; + Some(super::app::ResolvedGlobalParam { + name: p.name.clone(), + location: p.location, + target: p.target.clone(), + value: val, + }) + }) + .collect(); + Ok(super::app::BindingEntry { + doc: doc.clone(), + auth_provider, + http_config: prepared.http_config.clone(), + global_headers, + global_params, + }) + } + + /// Wrap a typed handler function into a [`CliCommandHandler`] that + /// automatically downcasts the binding context to + /// [`AppContext`](super::AppContext). + /// + /// Use this with [`CliApp::command()`](crate::app::CliApp::command) + /// or [`CliApp::command_under()`](crate::app::CliApp::command_under): + /// + /// ```rust,ignore + /// CliApp::new("my-cli") + /// .binding(OpenApiBinding::new().spec(include_str!("openapi.yaml"))) + /// .command(my_cmd(), OpenApiBinding::handler(my_handler)) + /// .run() + /// ``` + pub fn handler( + f: fn(&clap::ArgMatches, &super::AppContext) -> Result<(), crate::error::CliError>, + ) -> crate::app::CliCommandHandler { + Box::new(move |matches: &clap::ArgMatches, ctx: &dyn std::any::Any| { + let ctx = ctx.downcast_ref::().ok_or_else(|| { + crate::error::CliError::Validation( + "handler requires an OpenAPI binding context".into(), + ) + })?; + f(matches, ctx) + }) + } + +} + +impl Binding for OpenApiBinding { + fn name(&self) -> &str { + &self.inner.name + } + + fn set_cli_name(&mut self, name: &str) { + self.inner.name = name.to_string(); + } + + fn set_root_auth(&mut self, bindings: &[(String, crate::auth::SchemeBinding)]) { + // Root-level auth bindings are prepended to the inner CliApp's + // auth_bindings. If the binding also has its own auth_scheme_env() + // calls, those take priority (they appear later and override). + let mut merged = bindings.to_vec(); + merged.extend(std::mem::take(&mut self.inner.auth_bindings)); + self.inner.auth_bindings = merged; + } + + fn set_root_global_parameters( + &mut self, + params: &[crate::openapi::discovery::GlobalParameter], + ) { + // Root-level global parameters are prepended to the inner CliApp's + // builder_global_parameters. Any parameter the binding declared + // directly (via its own `.global_parameter()` call) takes priority + // and suppresses the same-named root parameter — mirroring how + // binding-level auth overrides root auth by scheme name. + let binding_names: std::collections::HashSet = self + .inner + .builder_global_parameters + .iter() + .map(|p| p.name.clone()) + .collect(); + let mut merged: Vec = params + .iter() + .filter(|p| !binding_names.contains(&p.name)) + .cloned() + .collect(); + merged.extend(std::mem::take(&mut self.inner.builder_global_parameters)); + self.inner.builder_global_parameters = merged; + } + + fn validate_auth(&self) -> Result<(), CliError> { + // Only validate when root-level auth is being used (auth_bindings + // is non-empty). If the binding has no auth bindings at all, it's + // intentionally running unauthenticated — no validation needed. + if self.inner.auth_bindings.is_empty() { + return Ok(()); + } + let prepared = self.ensure_prepared()?; + let registered: std::collections::HashSet<&str> = self + .inner + .auth_bindings + .iter() + .map(|(name, _)| name.as_str()) + .collect(); + let mut missing: Vec<&str> = Vec::new(); + for scheme_name in prepared.doc.security_schemes.keys() { + if !registered.contains(scheme_name.as_str()) { + missing.push(scheme_name.as_str()); + } + } + if !missing.is_empty() { + missing.sort(); + // Warn rather than fail — multi-spec binaries may intentionally + // bind only a subset of schemes (e.g. Twilio binds basic auth + // but not the IAM OAuth2 schemes). + tracing::warn!( + "Spec declares security scheme(s) [{}] with no .auth() binding. \ + Those endpoints will run unauthenticated.", + missing.join(", "), + ); + } + Ok(()) + } + + fn spec_document(&self, raw: bool) -> Result, CliError> { + self.inner.spec_yaml(raw) + } + + fn schema(&self, path: &[String]) -> Result, CliError> { + let prepared = self.ensure_prepared()?; + let effective_path = match &self.command_namespace { + Some(ns) if path.first().map(|s| s.as_str()) == Some(ns.as_str()) => &path[1..], + // Non-empty path that doesn't start with our namespace — this + // binding doesn't own it. + Some(_) if !path.is_empty() => return Ok(None), + _ => path, + }; + let schema = super::help::build_schema(&prepared.doc, effective_path); + match (&self.command_namespace, schema) { + (Some(ns), Some(value)) => Ok(Some(prefix_schema_operations(value, ns))), + (_, schema) => Ok(schema), + } + } + + fn build_command(&self) -> Result { + let prepared = self.ensure_prepared()?; + let cli = commands::build_cli(&prepared.doc) + .subcommand(crate::openapi::skill_emitter::generate_skills_command()); + let mut cli = self.inner.decorate_command(&prepared.doc, cli); + + // Register global -- flags for CLI-bound auth sources + // so clap knows about them before parsing. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + for arg_name in &cli_auth_args { + let kebab = arg_name.replace('_', "-"); + cli = cli.arg( + clap::Arg::new(arg_name.clone()) + .long(kebab) + .global(true) + .value_name(arg_name.to_uppercase()) + .help("Auth credential"), + ); + } + + // Wrap all spec-derived subcommands under the namespace prefix. + if let Some(ref ns) = self.command_namespace { + cli = wrap_subcommands_under_namespace(cli, ns); + } + + Ok(cli) + } + + fn dispatch<'a>( + &'a self, + root_matches: &'a clap::ArgMatches, + _sub_matches: &'a clap::ArgMatches, + _op_path: &'a [String], + ) -> BoxFuture<'a, Result> { + // Clone the Arc so we don't hold the lock across the await. + let prepared = match self.ensure_prepared() { + Ok(p) => p, + Err(e) => return Box::pin(async move { Err(e) }), + }; + + // Strip the namespace prefix from op_path for internal routing. + let effective_op_path: &[String] = match &self.command_namespace { + Some(ns) if _op_path.first().map(|s| s.as_str()) == Some(ns.as_str()) => { + &_op_path[1..] + } + _ => _op_path, + }; + + // Intercept `generate-skills` — it's not a spec operation. + if effective_op_path == ["generate-skills"] { + let output_dir = _sub_matches.get_one::("output-dir"); + let result = self.inner.handle_generate_skills( + output_dir.map(|s| s.as_str()), + &prepared.doc, + ); + return Box::pin(async move { + result?; + Ok(DispatchResult::Handled) + }); + } + + Box::pin(async move { + // If any auth source uses CLI flags, finalize them against + // the parsed matches and rebuild the auth provider. + let cli_auth_args = crate::auth::collect_binding_cli_args(&self.inner.auth_bindings); + let auth_provider = if cli_auth_args.is_empty() { + prepared.auth_provider.clone() + } else { + let matches_arc = std::sync::Arc::new(root_matches.clone()); + let finalized = crate::auth::finalize_bindings( + self.inner.auth_bindings.clone(), + &matches_arc, + ); + self.inner.build_auth_provider_from_finalized(&finalized, &prepared.doc) + }; + + // Apply server-variable substitutions to a local copy of the doc + // if any server vars are registered. + let mut doc_owned; + let doc = if self.inner.server_vars.is_empty() { + &prepared.doc + } else { + doc_owned = prepared.doc.clone(); + self.inner.apply_server_vars(&mut doc_owned, root_matches); + &doc_owned + }; + + // Walk the subcommand tree from root to find the target method. + // When a namespace is set the clap tree has an extra wrapper + // level (`cli `). Skip past it so the + // doc's resource names align with the subcommand chain. + let resolve_from = match &self.command_namespace { + Some(ns) => root_matches + .subcommand_matches(ns.as_str()) + .unwrap_or(root_matches), + None => root_matches, + }; + let (method, matched_args) = + super::resolve_method_from_matches(doc, resolve_from)?; + + let params_override = matched_args + .get_one::("params") + .map(|s| s.as_str()); + // `collect_params_from_flags` may call `resolve_file_refs` which + // performs blocking `std::fs::read` I/O. Wrap in `block_in_place` + // so the tokio runtime can schedule other work while the thread is + // parked on disk reads. + let params = tokio::task::block_in_place(|| { + super::app::collect_params_from_flags( + matched_args, + method, + params_override, + ) + })?; + let params_json_string = serde_json::to_string(¶ms) + .map_err(|e| CliError::Validation(format!("Failed to serialize params: {e}")))?; + let params_json: Option<&str> = if params.is_empty() { + None + } else { + Some(¶ms_json_string) + }; + + let body_json_owned = crate::cli_args::resolve_body_json(matched_args)?; + let body_json = body_json_owned.as_deref(); + + let dry_run = matched_args.get_flag("dry-run"); + let debug = root_matches.get_flag("debug"); + + let pagination = super::app::build_pagination_config(matched_args, doc, &self.inner.name); + + let no_extract = matched_args.get_flag("no-extract"); + let no_retry = matched_args.get_flag("no-retry"); + let no_stream = matched_args + .try_get_one::("no-stream") + .ok() + .flatten() + .copied() + .unwrap_or(false); + + let binary_body_path = method + .binary_request_body + .as_ref() + .and_then(|b| { + matched_args + .try_get_one::(&b.flag_name) + .ok() + .flatten() + .map(|s| s.as_str()) + }); + + // Validate binary body path for dangerous characters. Validate the + // SAME string the executor will use as the file path — for plain + // and `@`-prefixed values that's the input with the optional `@` + // (or `@file://` / `@data://` scheme) stripped; for the `\@` + // escape that's the literal `@`. Unlike the multipart and + // JSON-shorthand sites, the binary-body escape still opens a file + // (see `BinaryBodySource::parse` → `File { .. }`), so path-shape + // validation must apply in both branches. FER-10436, FER-10532. + // + // Stdin is only the `Auto`-mode `-` sentinel; an explicit scheme + // (`@file://-` / `@data://-`) is a literal filename and is still + // validated. + if let Some(path_str) = binary_body_path { + let flag = method.binary_request_body.as_ref() + .map(|b| b.flag_name.as_str()).unwrap_or("file"); + let (inner, is_stdin) = match executor::parse_at_ref(path_str) { + executor::AtRef::File { path, mode: executor::AtMode::Auto } => { + let is_dash = path.as_ref() == "-"; + (path, is_dash) + } + executor::AtRef::File { path, .. } => (path, false), + executor::AtRef::Escaped(literal) => { + (std::borrow::Cow::Owned(literal), false) + } + executor::AtRef::Plain(s) => { + (std::borrow::Cow::Borrowed(s), s == "-") + } + }; + if !is_stdin { + crate::output::reject_dangerous_chars(inner.as_ref(), &format!("--{flag}"))?; + } + } + + let global_header_overrides = super::app::build_global_header_overrides( + matched_args, + doc, + method, + ¶ms, + )?; + + let global_param_overrides = super::app::build_global_parameter_overrides( + matched_args, + doc, + method, + ¶ms, + )?; + + // --base-url flag wins; otherwise {NAME}_BASE_URL env var. + let base_url_override_owned = + crate::cli_args::resolve_base_url_override(root_matches, &self.inner.name)?; + let base_url_override = base_url_override_owned.as_deref(); + + // --user-agent-suffix flag wins; otherwise {NAME}_USER_AGENT_SUFFIX + // env var (resolved inside HttpConfig). Apply the flag override to + // a clone so the client this request builds carries it. + let http_config = prepared.http_config.clone().with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(root_matches), + ); + + // Read --output flag for binary response file writing. The literal + // `-` is a stdout sentinel (curl/wget convention) and bypasses + // path validation — handle_binary_response branches on it to + // stream raw bytes to stdout instead of touching the filesystem. + // Every other value flows through validate_safe_file_path, which + // rejects control characters and requires the parent directory to + // exist, but does not sandbox the path to CWD — the file is + // written wherever the user points it (final-component symlinks + // are still refused at open time via O_NOFOLLOW). + let output_path_owned = matched_args + .try_get_one::("output") + .ok() + .flatten() + .cloned(); + let output_path_buf = match output_path_owned.as_deref() { + Some("-") => None, + Some(p) => Some(crate::validate::validate_safe_file_path(p, "--output")?), + None => None, + }; + let output_path = if output_path_owned.as_deref() == Some("-") { + Some("-") + } else { + output_path_buf.as_deref().and_then(|p| p.to_str()) + }; + + // Collect multipart/form-data parts from CLI flags for operations + // that declare a `multipart/form-data` body. `None` for all others. + let multipart_parts = super::app::collect_multipart_parts(method, matched_args)?; + + let pipeline = crate::formatter::OutputPipeline::from_matches( + root_matches, + &self.inner.name, + ) + .map_err(|e| CliError::Validation(e.to_string()))?; + + if pipeline.is_raw() && pagination.page_all { + return Err(CliError::Validation( + "--format raw is incompatible with --page-all".to_string(), + )); + } + if pipeline.is_http() && pagination.page_all { + return Err(CliError::Validation( + "--format http is incompatible with --page-all".to_string(), + )); + } + + // When --page-all is active on a TTY without --no-pager, + // let the executor write directly to the pager (capture_output + // = false). The executor spawns the pager and returns None, + // which maps to DispatchResult::Handled below. + let use_pager = pagination.page_all + && !pagination.no_pager + && std::io::stdout().is_terminal(); + let capture_output = !pipeline.is_raw() && !pipeline.is_http() && !use_pager; + + let result = executor::execute_method( + doc, + method, + params_json, + body_json, + &auth_provider, + output_path, + None, // upload + binary_body_path, + multipart_parts, + dry_run, + &pagination, + &pipeline, + capture_output, + base_url_override, + &http_config, + no_extract, + no_retry, + no_stream, + debug, + &global_header_overrides, + &global_param_overrides, + ) + .await?; + + match result { + Some(value) => Ok(DispatchResult::Value(value)), + None => Ok(DispatchResult::Handled), + } + }) + } + + fn binding_context( + &self, + matches: &clap::ArgMatches, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let debug = matches.get_flag("debug"); + let base_url_override = + crate::cli_args::resolve_base_url_override(matches, &self.inner.name)?; + let http_config = entry.http_config.with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(matches), + ); + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + http_config, + entry.global_headers, + entry.global_params, + ).with_quiet(quiet) + .with_base_url_override(base_url_override) + .with_debug(debug); + Ok(Some(Box::new(ctx))) + } + + fn merge_binding_context( + &self, + matches: &clap::ArgMatches, + existing: Option>, + ) -> Result>, CliError> { + let entry = self.build_binding_entry(matches)?; + let quiet = matches + .try_get_one::("quiet") + .ok() + .flatten() + .copied() + .unwrap_or(false); + let debug = matches.get_flag("debug"); + let base_url_override = + crate::cli_args::resolve_base_url_override(matches, &self.inner.name)?; + let entry = super::app::BindingEntry { + http_config: entry.http_config.with_user_agent_suffix_override( + crate::cli_args::resolve_user_agent_suffix_override(matches), + ), + ..entry + }; + match existing { + Some(ctx_box) => match ctx_box.downcast::() { + Ok(mut ctx) => { + ctx.add_entry(entry); + ctx.debug = debug; + ctx.quiet = quiet; + ctx.base_url_override = base_url_override; + Ok(Some(ctx as Box)) + } + Err(original) => { + // Different binding type — start a new AppContext, + // discard the incompatible context. + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + entry.global_params, + ).with_quiet(quiet) + .with_base_url_override(base_url_override) + .with_debug(debug); + let _ = original; + Ok(Some(Box::new(ctx))) + } + }, + None => { + let ctx = super::AppContext::new( + entry.doc, + entry.auth_provider, + entry.http_config, + entry.global_headers, + entry.global_params, + ).with_quiet(quiet) + .with_base_url_override(base_url_override) + .with_debug(debug); + Ok(Some(Box::new(ctx))) + } + } + } +} + +// ── Namespace helpers ────────────────────────────────────────────── + +/// Move all subcommands of `cmd` into an intermediate +/// `Command::new(namespace)` wrapper, returning a rebuilt command whose +/// sole subcommand is the namespace node. Global args, about, and +/// after_help are preserved on the outer command. +fn wrap_subcommands_under_namespace(cmd: clap::Command, namespace: &str) -> clap::Command { + let subs: Vec = cmd.get_subcommands().cloned().collect(); + + let mut ns_cmd = clap::Command::new(namespace.to_string()) + .about("API commands") + .subcommand_required(true) + .arg_required_else_help(true); + for sub in subs { + ns_cmd = ns_cmd.subcommand(sub); + } + + // Rebuild the outer command: same name, global args, about, and + // after_help — but with only the namespace wrapper as a subcommand. + let mut new_cmd = clap::Command::new(cmd.get_name().to_string()) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true); + if let Some(about) = cmd.get_about() { + new_cmd = new_cmd.about(about.to_string()); + } + if let Some(after_help) = cmd.get_after_help() { + new_cmd = new_cmd.after_help(after_help.to_string()); + } + for arg in cmd.get_arguments() { + new_cmd = new_cmd.arg(arg.clone()); + } + new_cmd.subcommand(ns_cmd) +} + +/// Prefix the `"operation"` field of every entry in a schema value with +/// `"."`. Handles both the plain `[{operation, …}]` array and +/// the `{sdkVariables, operations}` envelope. +fn prefix_schema_operations(value: serde_json::Value, namespace: &str) -> serde_json::Value { + fn prefix_ops(arr: Vec, ns: &str) -> Vec { + arr.into_iter() + .map(|mut entry| { + if let Some(op) = entry.get("operation").and_then(|v| v.as_str()) { + entry["operation"] = serde_json::Value::String( + format!("{ns}.{op}"), + ); + } + entry + }) + .collect() + } + + match value { + serde_json::Value::Array(arr) => { + serde_json::Value::Array(prefix_ops(arr, namespace)) + } + serde_json::Value::Object(mut obj) => { + if let Some(serde_json::Value::Array(ops)) = obj.remove("operations") { + obj.insert( + "operations".to_string(), + serde_json::Value::Array(prefix_ops(ops, namespace)), + ); + } + // Single-operation schema (leaf query) has a top-level + // `"operation"` key instead of the plural `"operations"`. + if let Some(op) = obj.get("operation").and_then(|v| v.as_str()) { + let prefixed = format!("{namespace}.{op}"); + obj.insert("operation".to_string(), serde_json::Value::String(prefixed)); + } + serde_json::Value::Object(obj) + } + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefix_schema_operations_array() { + let input = serde_json::json!([ + { "operation": "users.get", "httpMethod": "GET" }, + { "operation": "files.list", "httpMethod": "GET" }, + ]); + let result = prefix_schema_operations(input, "api"); + let arr = result.as_array().unwrap(); + assert_eq!(arr[0]["operation"], "api.users.get"); + assert_eq!(arr[1]["operation"], "api.files.list"); + } + + #[test] + fn prefix_schema_operations_envelope() { + let input = serde_json::json!({ + "sdkVariables": [], + "operations": [ + { "operation": "users.get", "httpMethod": "GET" }, + ], + }); + let result = prefix_schema_operations(input, "api"); + let ops = result["operations"].as_array().unwrap(); + assert_eq!(ops[0]["operation"], "api.users.get"); + } + + #[test] + fn wrap_subcommands_under_namespace_moves_subs() { + let cmd = clap::Command::new("test") + .about("Test CLI") + .arg( + clap::Arg::new("debug") + .long("debug") + .global(true) + .action(clap::ArgAction::SetTrue), + ) + .subcommand( + clap::Command::new("users") + .subcommand(clap::Command::new("get")), + ) + .subcommand( + clap::Command::new("files") + .subcommand(clap::Command::new("list")), + ); + + let wrapped = wrap_subcommands_under_namespace(cmd, "api"); + + // The namespace should be the only top-level subcommand. + let top_names: Vec<&str> = wrapped + .get_subcommands() + .map(|s| s.get_name()) + .collect(); + assert_eq!(top_names, vec!["api"]); + + // Original subcommands live under the namespace. + let ns_cmd = wrapped + .get_subcommands() + .find(|s| s.get_name() == "api") + .unwrap(); + let ns_sub_names: Vec<&str> = ns_cmd + .get_subcommands() + .map(|s| s.get_name()) + .collect(); + assert!(ns_sub_names.contains(&"users")); + assert!(ns_sub_names.contains(&"files")); + + // Global arg is preserved on the outer command. + assert!(wrapped + .get_arguments() + .any(|a| a.get_id().as_str() == "debug")); + } + + #[test] + fn prefix_schema_operations_leaf_object() { + let input = serde_json::json!({ + "operation": "users.get", + "httpMethod": "GET", + "parameters": [], + }); + let result = prefix_schema_operations(input, "api"); + assert_eq!(result["operation"], "api.users.get"); + assert_eq!(result["httpMethod"], "GET"); + } + + #[test] + #[should_panic(expected = "collides with a reserved framework subcommand")] + fn command_namespace_rejects_reserved_name() { + OpenApiBinding::default().command_namespace("auth"); + } + + fn gp(name: &str, env: Option<&str>) -> crate::openapi::discovery::GlobalParameter { + crate::openapi::discovery::GlobalParameter { + name: name.into(), + parameter_name: None, + location: crate::openapi::discovery::GlobalParameterLocation::Query, + target: name.into(), + env: env.map(Into::into), + default: None, + optional: false, + apply: crate::openapi::discovery::GlobalParameterApplyMode::Auto, + docs: None, + } + } + + #[test] + fn set_root_global_parameters_populates_binding() { + // Root-declared params (like `CliApp::global_parameter`) are handed + // to the binding via `set_root_global_parameters` and land in the + // inner CliApp's builder_global_parameters. + let mut binding = OpenApiBinding::new(); + binding.set_root_global_parameters(&[gp("currency", Some("CURRENCY_ENV")), gp("region", None)]); + let names: Vec<&str> = binding + .inner + .builder_global_parameters + .iter() + .map(|p| p.name.as_str()) + .collect(); + assert_eq!(names, vec!["currency", "region"]); + } + + #[test] + fn set_root_global_parameters_binding_level_wins() { + // A parameter declared directly on the binding takes precedence over + // a same-named root parameter (dedup by name, binding wins), while + // root-only params are still merged in. + let binding = OpenApiBinding::new().global_parameter(gp("currency", Some("BINDING_ENV"))); + let mut binding = binding; + binding.set_root_global_parameters(&[gp("currency", Some("ROOT_ENV")), gp("region", None)]); + + let params = &binding.inner.builder_global_parameters; + let currency = params.iter().find(|p| p.name == "currency").unwrap(); + assert_eq!( + currency.env.as_deref(), + Some("BINDING_ENV"), + "binding-level param must win over the same-named root param" + ); + assert_eq!( + params.iter().filter(|p| p.name == "currency").count(), + 1, + "no duplicate currency entry" + ); + assert!( + params.iter().any(|p| p.name == "region"), + "root-only param must still be merged in" + ); + } +} diff --git a/src/openapi/commands.rs b/src/openapi/commands.rs new file mode 100644 index 0000000..16c8014 --- /dev/null +++ b/src/openapi/commands.rs @@ -0,0 +1,2305 @@ +//! CLI Command Builder +//! +//! Builds a dynamic `clap::Command` tree from the internal API representation. + +use clap::builder::{PossibleValue, PossibleValuesParser}; +use clap::{Arg, Command}; + +use std::borrow::Cow; +use std::collections::HashMap; + +use crate::openapi::discovery::{ + Availability, FernEnumValue, MethodParameter, MultipartField, RestDescription, RestResource, + SdkGroupInfo, +}; +use crate::text::{sanitize_flag_name, to_kebab_flag}; + +/// Filter the document in-place so only operations matching at least +/// one of `active_audiences` survive into the command tree. Mirrors +/// fern-api/fern's OpenAPI importer behavior in +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/generateIr.ts:117-143`: +/// +/// ```text +/// if (audiences.length > 0 +/// && !audiences.some(a => operationAudiences.includes(a))) { +/// continue; +/// } +/// ``` +/// +/// Semantics, mirroring fern verbatim: +/// +/// 1. **No active audience** (`active_audiences` is empty) — every +/// operation survives regardless of its tags. +/// 2. **One or more active audiences** — an operation is kept only if +/// its `audiences` set intersects `active_audiences` (set OR, not +/// AND). Operations with empty `audiences` are dropped, since +/// `[].some(...)` is always false. +/// 3. **Untagged operations are NOT included** when a filter is active — +/// deliberate fern parity (a "no audience" tag is treated as "belongs +/// to no audience", not "belongs to all audiences"). +/// +/// After the per-operation prune, empty resource groups (no methods, +/// no non-empty children) are collapsed so they don't surface as bare +/// subcommands with no leaves — same approach used by the +/// `x-fern-ignore` pass in `parser.rs::prune_empty_resources`. +pub fn filter_doc_by_audiences(doc: &mut RestDescription, active_audiences: &[String]) { + if active_audiences.is_empty() { + return; + } + filter_resources_by_audiences(&mut doc.resources, active_audiences); +} + +/// Recursive worker for [`filter_doc_by_audiences`]. Drops methods that +/// don't intersect `active`, then recurses into nested resources, then +/// finally prunes resources that ended up empty. +fn filter_resources_by_audiences( + resources: &mut std::collections::HashMap, + active: &[String], +) { + resources.retain(|_, resource| { + resource + .methods + .retain(|_, method| method_matches_audiences(&method.audiences, active)); + filter_resources_by_audiences(&mut resource.resources, active); + !resource.methods.is_empty() || !resource.resources.is_empty() + }); +} + +/// Membership check mirroring fern's +/// `audiences.some(a => operationAudiences.includes(a))`. The names are +/// compared as opaque strings (case-sensitive, no normalization) so a +/// preset `audiences(["Public"])` and an operation tagged +/// `x-fern-audiences: [public]` deliberately do NOT match — matching +/// how the upstream importer treats audience names as identifiers. +fn method_matches_audiences(method_audiences: &[String], active: &[String]) -> bool { + active.iter().any(|a| method_audiences.iter().any(|m| m == a)) +} + +/// Prepends the availability badge (e.g. `[BETA] `) to `text` when one is +/// present. Falls back to `text` unchanged for generally-available items +/// and items with no availability marker. +fn with_availability_badge(text: &str, availability: Option) -> String { + match availability.and_then(Availability::badge) { + Some(badge) if text.is_empty() => badge.to_string(), + Some(badge) => format!("{badge} {text}"), + None => text.to_string(), + } +} + +/// Names of built-in flags that must not be duplicated by parameter-derived flags. +pub(crate) const BUILTIN_FLAG_NAMES: &[&str] = &[ + "params", + "output", + "json", + "format", + "dry-run", + "base-url", + "page-all", + "page-limit", + "page-delay", + "no-pager", + "no-extract", + "no-retry", + "no-stream", + "quiet", + "query", + "help", + "debug", + "schema", + "user-agent-suffix", +]; + +/// The non-auth portion of the `--help` footer. Auth env vars are +/// computed dynamically from bindings by `CliApp::run_async` and +/// prepended via `Command::after_help` — keeping them out of this string +/// avoids stale `{NAME}_API_KEY` boilerplate. +pub fn after_help_footer(binary_name: &str) -> String { + let prefix = binary_name.to_uppercase().replace('-', "_"); + // The suffix flag/env names default to `--user-agent-suffix` / + // `_USER_AGENT_SUFFIX` but can be renamed at generation time. + let ua_env = format!("{prefix}{}", crate::user_agent::suffix_env_segment()); + let ua_flag = crate::user_agent::suffix_flag(); + format!( + "Environment variables:\n \ + {prefix}_BASE_URL Override the API base URL\n \ + {prefix}_CA_BUNDLE Path to PEM file with extra trust roots (or SSL_CERT_FILE)\n \ + {prefix}_INSECURE=1 Skip TLS verification (debugging only)\n \ + {prefix}_PROXY HTTP(S) proxy URL\n \ + {prefix}_TIMEOUT_SECS Total request timeout\n \ + {ua_env} Product token appended to the User-Agent (e.g. my-app/1.0; --{ua_flag} wins)\n\n\ + Standard env vars (HTTPS_PROXY / HTTP_PROXY / NO_PROXY / SSL_CERT_FILE) are also honored." + ) +} + +/// Builds the full CLI command tree from an API description. +pub fn build_cli(doc: &RestDescription) -> Command { + let about_text = doc + .title + .clone() + .unwrap_or_else(|| format!("{} CLI", doc.name)); + let after_help = after_help_footer(&doc.name); + let mut root = Command::new(doc.name.clone()) + .about(about_text) + .after_help(after_help) + .term_width(200) + .subcommand_required(true) + .arg_required_else_help(true) + .arg( + clap::Arg::new("dry-run") + .long("dry-run") + .help("Validate the request locally without sending it to the API") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("format") + .long("format") + .help("Output format: json, table, yaml, csv, raw, jsonl, http. Default: table when stdout is a TTY, json when piped. Override default with _OUTPUT env var. raw emits unmodified server response bytes. jsonl emits one compact JSON value per line (NDJSON). http emits the full HTTP response (status line + headers + body).") + .value_name("FORMAT") + .global(true), + ) + .arg( + clap::Arg::new("base-url") + .long("base-url") + .help("Override the API base URL (e.g. for testing against a mock server)") + .value_name("URL") + .global(true), + ) + .arg( + clap::Arg::new("user-agent-suffix") + .long(crate::user_agent::suffix_flag()) + .help(format!( + "Product token appended to the User-Agent (e.g. my-app/1.0), so a tool built on top of this CLI can tag its traffic. Takes precedence over {}.", + crate::user_agent::suffix_env_segment() + )) + .value_name("TOKEN") + .global(true), + ) + .arg( + clap::Arg::new("quiet") + .long("quiet") + .short('q') + .help("Suppress stdout output on success (errors still go to stderr)") + .action(clap::ArgAction::SetTrue) + .global(true), + ) + .arg( + clap::Arg::new("query") + .long("query") + .help( + "JMESPath expression applied to the response before formatting. \ + For streaming responses, events whose projection is null are \ + suppressed (use as a per-event filter).", + ) + .value_name("EXPR") + .global(true), + ); + + // Add resource subcommands + let mut resource_names: Vec<_> = doc.resources.keys().collect(); + resource_names.sort(); + for name in resource_names { + let resource = &doc.resources[name]; + if let Some(cmd) = build_resource_command(name, resource, &doc.groups) { + root = root.subcommand(cmd); + } + } + + root +} + +/// Resolve the `about()` line for a group's clap subcommand. Returns +/// the `summary` from a matching [`SdkGroupInfo`] entry (sourced from +/// the document-root `x-fern-groups` extension) when present; falls +/// back to the legacy `Operations on ''` label otherwise. The +/// fallback preserves the current default behavior unchanged for any +/// group identifier that doesn't appear in `x-fern-groups`. +pub(crate) fn group_about_text(name: &str, groups: &HashMap) -> String { + groups + .get(name) + .and_then(|info| info.summary.clone()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| format!("Operations on '{name}'")) +} + +/// Resolve the `long_about()` line for a group's clap subcommand from +/// the document-root `x-fern-groups` extension's `description` field. +/// `None` when the group has no entry or the entry omits `description` +/// — clap then falls back to the `about()` text for `--help`. +pub(crate) fn group_long_about_text( + name: &str, + groups: &HashMap, +) -> Option { + groups + .get(name) + .and_then(|info| info.description.clone()) + .filter(|s| !s.is_empty()) +} + +/// Stringify a parameter's resolved client-side default value for +/// clap's `Arg::default_value`. Strings pass through verbatim; numbers +/// and booleans use their natural lexical form (e.g. `100`, `true`); +/// other JSON shapes (arrays, objects) fall through to compact JSON — +/// but in practice `x-fern-default` only carries scalar literals so the +/// scalar branch is the load-bearing case. +/// +/// Returns `None` for `Value::Null` and the `None` input so the caller +/// can skip setting any clap default. +pub(crate) fn default_value_for_clap(value: &Option) -> Option { + match value.as_ref()? { + serde_json::Value::Null => None, + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Bool(b) => Some(b.to_string()), + serde_json::Value::Number(n) => Some(n.to_string()), + other => Some(other.to_string()), + } +} + +/// Format an OpenAPI standard `default:` value as a trailing +/// documentation suffix to append to a flag's help text. Renders as +/// `[default: ]` so the user sees the same shape as clap's +/// auto-generated `[default: ...]` for `x-fern-default` — the help +/// surface intentionally does not distinguish client-side defaults +/// (sent on the wire) from server-side defaults (doc-only). The split +/// stays a wire-behavior concern, not a documentation concern. +/// +/// Returns the bare scalar rendering with a leading space so callers +/// can concatenate it directly onto `Arg::help`. +pub(crate) fn documentation_default_help_suffix( + value: &Option, +) -> Option { + let rendered = match value.as_ref()? { + serde_json::Value::Null => return None, + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + other => other.to_string(), + }; + Some(format!(" [default: {rendered}]")) +} + +/// Recursively builds a Command for a resource. +/// Returns None if the resource has no methods or sub-resources. +/// +/// `groups` carries the parsed document-root `x-fern-groups` block; when +/// a matching entry exists for `name` it overrides the `about()`/ +/// `long_about()` text rendered in `--help`. Unmatched resources retain +/// the legacy `Operations on ''` label and alphabetical placement +/// so adding `x-fern-groups` is strictly additive. +fn build_resource_command( + name: &str, + resource: &RestResource, + groups: &HashMap, +) -> Option { + let mut cmd = Command::new(name.to_string()) + .about(group_about_text(name, groups)) + .subcommand_required(true) + .arg_required_else_help(true); + + if let Some(long_about) = group_long_about_text(name, groups) { + cmd = cmd.long_about(long_about); + } + + let mut has_children = false; + + // Add method subcommands + let mut method_names: Vec<_> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + + has_children = true; + + let about = crate::text::truncate_description( + method.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + let about = with_availability_badge(&about, method.availability); + + let mut method_cmd = Command::new(method_name.to_string()) + .about(about) + .arg( + Arg::new("params") + .long("params") + .help("Additional parameters as JSON (overrides individual flags)") + .value_name("JSON"), + ); + + // `-o, --output PATH` is only meaningful for operations that can + // return a binary body — the JSON path in `process_response` never + // consults `output_path`, so on pure-JSON ops the flag would + // silently no-op. Hide it where it does nothing rather than + // surface a misleading affordance in `--help`. Mixed-response ops + // (binary 2xx + JSON 4xx) still get the flag because the success + // path is what writes a file. + if method.has_binary_response { + method_cmd = method_cmd.arg( + Arg::new("output") + .long("output") + .short('o') + .help("Output file path for binary responses (use '-' to stream to stdout)") + .value_name("PATH"), + ); + } + + // Add --json flag for REST request bodies + if method.request.is_some() { + method_cmd = method_cmd.arg( + Arg::new("json") + .long("json") + .help("JSON request body (use `-` to read from stdin; auto-detected, errors if no data piped)") + .value_name("JSON|-"), + ); + } + + // Add a typed flag for operations with a binary request body + // (e.g. application/octet-stream). The file is streamed as the body + // with the content type declared in the spec. The flag name comes from + // `x-fern-parameter-name` on the requestBody, or defaults to `file` + // for `format: binary` schemas (else `body`). + // + // Accepts four forms: , @ (curl-style), `\@` (escape + // — send the literal value `@`), or `-` for stdin. + if let Some(ref binary) = method.binary_request_body { + method_cmd = method_cmd.arg( + Arg::new(binary.flag_name.clone()) + .long(binary.flag_name.clone()) + .value_name("PATH|@PATH|\\@LITERAL|-") + .help(format!( + "Body for the request (Content-Type: {}). Accepts:\n \ + plain filesystem path\n \ + @ same path (curl-style prefix)\n \ + \\@ escape: send literal value @\n \ + - read from stdin (sent chunked)", + binary.content_type, + )), + ); + } + + // Add per-field flags for multipart/form-data operations. + // Skip fields whose kebab name collides with a builtin flag, + // matching the regular-param convention above. + for field in &method.multipart_fields { + let kebab = to_kebab_flag(&field.wire_name); + if is_reserved_flag_name(&kebab) { + continue; + } + method_cmd = method_cmd.arg(build_multipart_field_arg(field)); + } + + // Pagination flags + method_cmd = method_cmd + .arg( + Arg::new("page-all") + .long("page-all") + .help("Auto-paginate through all results (NDJSON)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("page-limit") + .long("page-limit") + .help("Maximum number of pages to fetch (default: 10)") + .value_name("N") + .value_parser(clap::value_parser!(u32)), + ) + .arg( + Arg::new("page-delay") + .long("page-delay") + .help("Delay in milliseconds between page fetches (default: 100)") + .value_name("MS") + .value_parser(clap::value_parser!(u64)), + ) + .arg( + Arg::new("no-pager") + .long("no-pager") + .help("Disable pager even on interactive terminals") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("no-extract") + .long("no-extract") + .help( + "Disable x-fern-sdk-return-value extraction and print the full response body", + ) + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("no-retry") + .long("no-retry") + .help( + "Disable retries declared by x-fern-retries on this operation, \ + including network errors. Useful for debugging.", + ) + .action(clap::ArgAction::SetTrue), + ); + + // `--no-stream` is only meaningful on operations with + // `x-fern-streaming`. Registering it unconditionally would let + // clap accept it on unrelated ops and silently no-op, which + // hides spec/runtime mismatches; instead, expose it only where + // it does something so non-streaming siblings reject the flag + // up-front. + if method.streaming.is_some() { + method_cmd = method_cmd.arg( + Arg::new("no-stream") + .long("no-stream") + .help( + "Buffer the streaming response and print it as a single value once \ + complete (handy for piping into another JSON tool)", + ) + .action(clap::ArgAction::SetTrue), + ); + } + + // Generate individual flags from method parameters. + // + // Track (sanitized_flag → wire_name) to detect collisions where + // two distinct wire names produce the same CLI flag. + let mut flag_to_wire: HashMap = HashMap::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for param_name in param_names { + let param = &method.parameters[param_name]; + + // Flag name resolution uses `resolve_param_flag_name` — the + // single source of truth shared with the executor's + // missing-param hint (FER-10430). + let kebab_name = match resolve_param_flag_name(param, param_name) { + Some(name) => name, + None => { + tracing::warn!( + param = %param_name, + "skipping parameter with unsanitizable name", + ); + continue; + } + }; + + // Variable-bound path parameters get their value from a + // root-level global flag (registered in `app::run_async` from + // `doc.sdk_variables`) plus its env-var fallback. Skip before + // inserting into flag_to_wire so variable-bound params don't + // occupy a collision slot and block a later non-variable-bound + // param that sanitizes to the same flag name. + if param.variable_reference.is_some() { + continue; + } + + // Cross-parameter collision: two different wire names mapping + // to the same flag. Skip the second occurrence with a warning + // (load-time error would be ideal but the builder is infallible). + if let Some(existing_wire) = flag_to_wire.get(&kebab_name) { + tracing::warn!( + flag = %kebab_name, + wire1 = %existing_wire, + wire2 = %param_name, + "two parameters sanitize to the same flag --{kebab_name}; \ + keeping '{existing_wire}', skipping '{param_name}'", + ); + continue; + } + flag_to_wire.insert(kebab_name.clone(), param_name.clone()); + + let base_value_name = match param.param_type.as_deref() { + Some("string") => "STRING", + Some("integer") => "NUMBER", + Some("number") => "NUMBER", + Some("boolean") => "BOOLEAN", + Some("array") => "JSON_ARRAY", + Some("object") => "JSON_OBJECT", + _ => "VALUE", + }; + // Composite types never set `param.nullable`, so the `|null` + // sentinel suffix stays scalar-only without an explicit guard. + let value_name: Cow<'static, str> = if param.nullable { + Cow::Owned(format!("{base_value_name}|null")) + } else { + Cow::Borrowed(base_value_name) + }; + + let help_text = crate::text::truncate_description( + param.description.as_deref().unwrap_or(""), + crate::text::CLI_DESCRIPTION_LIMIT, + true, + ); + let help_text = with_availability_badge(&help_text, param.availability); + // When the CLI flag differs from the wire name — whether via + // `x-fern-parameter-name` rename or sanitization — surface + // the original wire name in `--help` so users can correlate + // the flag with the API docs / `--params` JSON. Synthetic + // `flag_name_override` injections already encode the wire + // name in their description, so they skip this. + let flag_differs_from_wire = param.flag_name_override.is_none() + && kebab_name != *param_name; + let help_text = if flag_differs_from_wire { + if help_text.is_empty() { + format!("(api: {param_name})") + } else { + format!("{help_text} (api: {param_name})") + } + } else { + help_text + }; + // Append the OpenAPI standard `default:` value as a + // `[default: ...]` suffix when it is the only default + // source. Same visual shape as clap's auto-rendered + // `[default: ...]` for `x-fern-default` — the user sees + // "there is a default" without being told whether the CLI + // or the server applies it. The CLI itself does not send + // this value on the wire (only `x-fern-default` populates + // `default_value` below). + let help_text = match documentation_default_help_suffix( + ¶m.documentation_default_value, + ) { + Some(suffix) => format!("{help_text}{suffix}"), + None => help_text, + }; + + let arg_id = param_clap_arg_id(param_name); + let mut arg = Arg::new(arg_id) + .long(kebab_name) + .value_name(value_name) + .help(help_text); + + // Only `x-fern-default` (lowered into `default_value`) + // becomes a clap default. The standard `default:` keyword + // is doc-only and handled above via the help-text suffix. + if let Some(default_str) = default_value_for_clap(¶m.default_value) { + arg = arg.default_value(default_str); + } + + // Environment-variable fallback (currently populated by the + // OpenAPI parser for synthetic idempotency-header params from + // `x-fern-idempotency-headers`, with overrides applied by + // `CliApp::idempotency_header_env`). Clap reads `.env(...)` + // when the flag is absent on the command line, giving us the + // same priority order — flag → env → default — used for auth + // sources. + if let Some(ref env_var) = param.env_var { + arg = arg.env(env_var.clone()); + } + + if let Some(ref enum_values) = param.enum_values { + arg = arg.value_parser(build_enum_value_parser(enum_values, param)); + } + + if param.repeated { + arg = arg.action(clap::ArgAction::Append); + } + + method_cmd = method_cmd.arg(arg); + } + + cmd = cmd.subcommand(method_cmd); + } + + // Add sub-resource subcommands (recursive) + let mut sub_names: Vec<_> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub_resource = &resource.resources[sub_name]; + if let Some(sub_cmd) = build_resource_command(sub_name, sub_resource, groups) { + has_children = true; + cmd = cmd.subcommand(sub_cmd); + } + } + + if has_children { + Some(cmd) + } else { + None + } +} + +/// Compute the clap arg ID for a parameter given its wire name. +/// +/// Normally the arg ID equals the wire name so the executor can look +/// values up by wire name directly. When the wire name itself collides +/// with a built-in flag's arg ID (e.g. `format`, `output`, `json`), we +/// suffix it with `-param` to avoid a clap duplicate-arg-ID panic. +/// +/// This function is also called by `collect_params_from_flags` so the +/// executor uses the same mangled ID that the command builder registered. +pub(crate) fn param_clap_arg_id(wire_name: &str) -> String { + if BUILTIN_FLAG_NAMES.contains(&wire_name) { + format!("{wire_name}-param") + } else { + wire_name.to_string() + } +} + +/// Resolve the CLI flag name for a parameter, replicating every step that +/// `build_resource_command` applies: override -> body-kebab vs +/// non-body-sanitize -> builtin-collision `-param` suffix. Both the +/// command builder and the executor's missing-param hint must agree on +/// what flag a parameter maps to — this shared helper is the single +/// source of truth. +/// +/// Returns `None` only when `sanitize_flag_name` rejects the name +/// (control characters, CJK, etc.). The caller should fall back to +/// `--params` guidance in that case. +pub(crate) fn resolve_param_flag_name(param: &MethodParameter, wire_name: &str) -> Option { + let mut flag = if let Some(override_flag) = param.flag_name_override.as_deref() { + override_flag.to_string() + } else { + let is_body = param.location.as_deref() == Some("body"); + let source = param.display_name.as_deref().unwrap_or(wire_name); + if is_body { + to_kebab_flag(source) + } else { + match sanitize_flag_name(source) { + Ok(name) => name, + Err(_) => return None, + } + } + }; + if is_reserved_flag_name(&flag) { + flag = format!("{flag}-param"); + } + Some(flag) +} + +/// Whether a parameter-derived flag long name is reserved by the runtime +/// and therefore must be mangled (`-param` suffix) to avoid a clap +/// duplicate-flag panic. Covers the always-present built-in flags plus a +/// customer-configured `userAgentSuffixFlag` name that would otherwise +/// clash with the consumer suffix flag. +fn is_reserved_flag_name(flag: &str) -> bool { + BUILTIN_FLAG_NAMES.contains(&flag) || crate::user_agent::collides_with_suffix_flag(flag) +} + +/// Build a `PossibleValuesParser` that respects an optional `x-fern-enum` +/// override. When the parameter has no `fern_enum` map, this is a plain +/// `PossibleValuesParser::new(wire_values)`. When it does, each wire value +/// gets an alias + per-value help string so `--help` renders the display +/// name and description while either the display name or wire value parses +/// successfully on the command line. +fn build_enum_value_parser( + wire_values: &[String], + param: &MethodParameter, +) -> PossibleValuesParser { + let mut possible: Vec = wire_values + .iter() + .map(|wire| { + let cfg = param + .fern_enum + .as_ref() + .and_then(|m| m.get(wire)); + build_possible_value(wire, cfg) + }) + .collect(); + // Null sentinel: when the param is nullable, accept `null` as a + // fourth (etc.) possible value so clap admits it. The conversion to + // `Value::Null` happens later in `collect_params_from_flags`. + if param.nullable { + possible.push(PossibleValue::new("null").help("Send JSON null.")); + } + PossibleValuesParser::from(possible) +} + +/// Construct a single `PossibleValue` from a wire value and its optional +/// `x-fern-enum` config. The display name (if set and different from the +/// wire value) becomes the canonical rendered name, with the wire value +/// as a parse-time alias. Descriptions surface as long-help text. +fn build_possible_value(wire: &str, cfg: Option<&FernEnumValue>) -> PossibleValue { + let display = cfg.and_then(|c| c.display_name.as_deref()); + let mut pv = match display { + Some(name) if name != wire => PossibleValue::new(name.to_string()).alias(wire.to_string()), + _ => PossibleValue::new(wire.to_string()), + }; + if let Some(desc) = cfg.and_then(|c| c.description.as_deref()) { + pv = pv.help(desc.to_string()); + } + pv +} + +/// Build a `clap::Arg` for a single [`MultipartField`]. File fields +/// accept a path (`` / `@` / `-` for stdin), or a `\@` +/// escape that sends the literal text `@` as the part value; +/// text fields accept a plain string value. +fn build_multipart_field_arg(field: &MultipartField) -> Arg { + let kebab = to_kebab_flag(&field.wire_name); + let (value_name, help_prefix) = if field.is_file { + ("PATH|@PATH|\\@LITERAL|-", "File to upload") + } else { + ("VALUE", "") + }; + + let help_text = match (&field.description, help_prefix) { + (Some(desc), "") => desc.clone(), + (Some(desc), prefix) => format!("{prefix}. {desc}"), + (None, prefix) if !prefix.is_empty() => prefix.to_string(), + _ => String::new(), + }; + + let mut arg = Arg::new(field.wire_name.clone()) + .long(kebab) + .value_name(value_name) + .help(help_text); + + if field.required { + arg = arg.required(true); + } + + arg +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{FernEnumValue, MethodParameter, RestMethod, RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "list".to_string(), + ..Default::default() + }, + ); + methods.insert( + "delete".to_string(), + RestMethod { + http_method: "DELETE".to_string(), + path: "delete".to_string(), + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "files".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_all_commands_always_shown() { + let doc = make_doc(); + let cmd = build_cli(&doc); + + let files_cmd = cmd + .find_subcommand("files") + .expect("files resource missing"); + + assert!(files_cmd.find_subcommand("list").is_some()); + assert!(files_cmd.find_subcommand("delete").is_some()); + } + + #[test] + fn test_root_uses_doc_name() { + let doc = make_doc(); + let cmd = build_cli(&doc); + assert_eq!(cmd.get_name(), "test-cli"); + } + + #[test] + fn test_method_params_become_flags() { + let mut params = HashMap::new(); + params.insert( + "uuid".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user UUID".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + params.insert( + "status".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by status".to_string()), + location: Some("query".to_string()), + enum_values: Some(vec!["active".to_string(), "inactive".to_string()]), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get-user".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/users/{uuid}".to_string(), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let users_cmd = cmd.find_subcommand("users").expect("users resource missing"); + let get_user_cmd = users_cmd + .find_subcommand("get-user") + .expect("get-user method missing"); + + // Verify individual flags exist + let args: Vec = get_user_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!(args.contains(&"uuid".to_string()), "uuid flag missing"); + assert!(args.contains(&"status".to_string()), "status flag missing"); + assert!(args.contains(&"params".to_string()), "params flag missing"); + } + + #[test] + fn test_variable_bound_param_skipped_from_per_op_flags() { + // Path parameters that carry `x-fern-sdk-variable` must NOT appear + // as per-operation flags. Their value comes from a root-level + // global flag registered in `app::run_async` from + // `doc.sdk_variables` (with env-var fallback). Mirrors Fern's + // openapi-ir-parser semantics: variables are constructor-style + // globals, not per-method arguments. + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Garden tenant".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + // A plain (non-variable-bound) path param on the same op still + // surfaces as a per-op flag. + params.insert( + "zoneId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Zone id".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/gardens/{gardenId}/zones/{zoneId}".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "zones".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "garden-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let zones_cmd = cmd + .find_subcommand("zones") + .expect("zones resource missing"); + let get_cmd = zones_cmd + .find_subcommand("get") + .expect("zones.get missing"); + let arg_ids: Vec = get_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!( + !arg_ids.contains(&"gardenId".to_string()), + "variable-bound path param should NOT be a per-op flag, got: {arg_ids:?}", + ); + assert!( + arg_ids.contains(&"zoneId".to_string()), + "plain path param should still surface as a per-op flag, got: {arg_ids:?}", + ); + } + + #[test] + fn test_nullable_scalar_renders_value_name_with_null_suffix() { + // A nullable scalar body param renders its value_name as `|null` + // so users discover the null sentinel from `--help`. + let mut params = HashMap::new(); + params.insert( + "userId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: true, + ..Default::default() + }, + ); + params.insert( + "count".to_string(), + MethodParameter { + param_type: Some("integer".to_string()), + location: Some("body".to_string()), + nullable: true, + ..Default::default() + }, + ); + params.insert( + "code".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + nullable: false, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "create".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + let cmd = build_cli(&doc); + let create = cmd + .find_subcommand("things") + .unwrap() + .find_subcommand("create") + .unwrap(); + + let value_name_for = |id: &str| -> String { + let arg = create + .get_arguments() + .find(|a| a.get_id().as_str() == id) + .unwrap_or_else(|| panic!("arg '{id}' missing")); + arg.get_value_names() + .unwrap_or(&[]) + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(",") + }; + + assert_eq!(value_name_for("userId"), "STRING|null"); + assert_eq!(value_name_for("count"), "NUMBER|null"); + assert_eq!( + value_name_for("code"), + "STRING", + "non-nullable scalar must NOT gain the |null suffix", + ); + } + + #[test] + fn test_builtin_flag_names_renamed_with_param_suffix() { + let mut params = HashMap::new(); + params.insert( + "format".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Response format".to_string()), + ..Default::default() + }, + ); + params.insert( + "output".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Output type".to_string()), + ..Default::default() + }, + ); + params.insert( + "real_param".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("A real param".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "test-method".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/test".to_string(), + parameters: params, + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let things_cmd = cmd + .find_subcommand("things") + .expect("things resource missing"); + let test_cmd = things_cmd + .find_subcommand("test-method") + .expect("test-method missing"); + + let args: Vec = test_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + assert!( + args.contains(&"real_param".to_string()), + "real_param flag missing" + ); + + // Wire names that collide with builtins get a `-param` suffix on + // both the arg ID and the long flag (FER-10430). + assert!( + args.contains(&"format-param".to_string()), + "format should be renamed to format-param, got: {args:?}" + ); + assert!( + args.contains(&"output-param".to_string()), + "output should be renamed to output-param, got: {args:?}" + ); + + // The long flags should also be suffixed. + let format_arg = test_cmd + .get_arguments() + .find(|a| a.get_id() == "format-param") + .expect("format-param arg missing"); + assert_eq!( + format_arg.get_long().unwrap(), + "format-param", + "format param should have --format-param long flag", + ); + } + + #[test] + fn test_sanitized_param_name_produces_correct_flag() { + let mut params = HashMap::new(); + params.insert( + "id:in".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + params.insert( + "date_created:min".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Min date".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let list_cmd = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .expect("things list missing"); + + // The arg IDs are the wire names (no builtin collision). + let arg_ids: Vec = list_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + assert!( + arg_ids.contains(&"id:in".to_string()), + "arg ID should be the wire name 'id:in', got: {arg_ids:?}", + ); + assert!( + arg_ids.contains(&"date_created:min".to_string()), + "arg ID should be the wire name 'date_created:min', got: {arg_ids:?}", + ); + + // But the long flags are sanitized. + let id_in = list_cmd + .get_arguments() + .find(|a| a.get_id() == "id:in") + .unwrap(); + assert_eq!(id_in.get_long().unwrap(), "id-in"); + + let date_min = list_cmd + .get_arguments() + .find(|a| a.get_id() == "date_created:min") + .unwrap(); + assert_eq!(date_min.get_long().unwrap(), "date-created-min"); + } + + #[test] + fn test_sanitized_flag_help_shows_wire_name() { + let mut params = HashMap::new(); + params.insert( + "id:in".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by ID".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let list_cmd = cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("list")) + .unwrap(); + let id_in = list_cmd + .get_arguments() + .find(|a| a.get_id() == "id:in") + .unwrap(); + let help = id_in.get_help().unwrap().to_string(); + assert!( + help.contains("api: id:in"), + "help text should include the wire name; got: {help}", + ); + } + #[test] + fn test_variable_bound_param_does_not_block_same_named_normal_param() { + // Finding 1: a variable-bound param (e.g. `projectId`) that + // sanitizes to the same flag as a normal param (e.g. + // `project_id` -> `project-id`) must not occupy a collision + // slot and prevent the normal param from getting a flag. + let mut params = HashMap::new(); + params.insert( + "projectId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + variable_reference: Some("projectId".to_string()), + ..Default::default() + }, + ); + params.insert( + "project_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Filter by project".to_string()), + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/projects/{projectId}/items".to_string(), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "items".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + let cmd = build_cli(&doc); + let list_cmd = cmd + .find_subcommand("items") + .and_then(|c| c.find_subcommand("list")) + .expect("items list missing"); + + let arg_ids: Vec = list_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // The normal query param `project_id` should be registered + // even though `projectId` (variable-bound) sanitizes to the + // same flag name `project-id`. + assert!( + arg_ids.contains(&"project_id".to_string()), + "project_id flag should be registered despite variable-bound projectId; got: {arg_ids:?}", + ); + + // The variable-bound param should NOT have a per-op flag. + assert!( + !arg_ids.contains(&"projectId".to_string()), + "variable-bound projectId should not appear as a per-op flag; got: {arg_ids:?}", + ); + + // Verify the long flag is correct. + let proj_arg = list_cmd + .get_arguments() + .find(|a| a.get_id() == "project_id") + .unwrap(); + assert_eq!(proj_arg.get_long().unwrap(), "project-id"); + } + + #[test] + fn test_resolve_param_flag_name_body_preserves_dots() { + // Finding 2: body params use to_kebab_flag which preserves + // dot-notation; the helper must replicate this. + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("body".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "address.street").unwrap(); + assert_eq!( + flag, "address.street", + "body param dots should be preserved via to_kebab_flag", + ); + } + + #[test] + fn test_resolve_param_flag_name_builtin_collision() { + // Finding 2: params colliding with builtins get `-param` suffix. + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "format").unwrap(); + assert_eq!( + flag, "format-param", + "builtin collision should append -param", + ); + } + + #[test] + fn test_resolve_param_flag_name_sanitizes_non_body() { + let param = MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "status:in").unwrap(); + assert_eq!(flag, "status-in"); + } + + #[test] + fn test_resolve_param_flag_name_uses_override() { + let param = MethodParameter { + flag_name_override: Some("custom-flag".to_string()), + location: Some("header".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "X-Custom-Header").unwrap(); + assert_eq!(flag, "custom-flag"); + } + + #[test] + fn test_resolve_param_flag_name_uses_display_name() { + let param = MethodParameter { + display_name: Some("searchQuery".to_string()), + location: Some("query".to_string()), + ..Default::default() + }; + let flag = resolve_param_flag_name(¶m, "filter_term").unwrap(); + assert_eq!(flag, "search-query"); + } + + #[test] + fn test_resolve_param_flag_name_is_idempotent() { + // Applying the helper twice must equal applying it once. The + // function calls sanitize_flag_name (idempotent) and may append + // `-param`. Since `format-param` is NOT in BUILTIN_FLAG_NAMES, + // a second application stays `format-param`. + + // Case 1: body param with dot-notation (`address.street`). + let body_param = MethodParameter { + location: Some("body".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&body_param, "address.street").unwrap(); + let twice = resolve_param_flag_name(&body_param, &once).unwrap(); + assert_eq!(once, twice, "body dot-notation must be idempotent"); + + // Case 2: flag_name_override — the override is used verbatim. + let override_param = MethodParameter { + flag_name_override: Some("custom-flag".to_string()), + location: Some("header".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&override_param, "X-Custom-Header").unwrap(); + let twice = resolve_param_flag_name(&override_param, &once).unwrap(); + assert_eq!(once, twice, "override case must be idempotent"); + + // Case 3: sanitize case (`id:in` → `id-in`). + let query_param = MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&query_param, "id:in").unwrap(); + let twice = resolve_param_flag_name(&query_param, &once).unwrap(); + assert_eq!(once, twice, "sanitize case must be idempotent"); + + // Case 4: builtin-collision case (`format` → `format-param`). + let collision_param = MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }; + let once = resolve_param_flag_name(&collision_param, "format").unwrap(); + assert_eq!(once, "format-param", "sanity: first application appends -param"); + let twice = resolve_param_flag_name(&collision_param, &once).unwrap(); + assert_eq!(once, twice, "builtin-collision case must be idempotent"); + } + + // ------------------------------------------------------------------ + // x-fern-enum → clap PossibleValue wiring + // + // These tests target `build_enum_value_parser` directly so the + // mapping between the `MethodParameter.fern_enum` map and clap's + // `PossibleValue` (canonical name + alias + help) can't drift. + // ------------------------------------------------------------------ + fn param_with_fern_enum( + wire_values: &[&str], + entries: &[(&str, Option<&str>, Option<&str>)], + ) -> MethodParameter { + let mut map = HashMap::new(); + for (wire, name, desc) in entries { + map.insert( + (*wire).to_string(), + FernEnumValue { + display_name: name.map(|s| s.to_string()), + description: desc.map(|s| s.to_string()), + }, + ); + } + MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(wire_values.iter().map(|s| s.to_string()).collect()), + fern_enum: Some(map), + ..Default::default() + } + } + + /// Drive `build_enum_value_parser` through a real `clap::Command` + /// `--help` render so the assertions cover what the user sees, not + /// just internals. Returns the lower-cased help text so substring + /// matches are case-insensitive. + fn render_arg_long_help(param: &MethodParameter) -> String { + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), param); + let cmd = Command::new("test").arg( + Arg::new("status") + .long("status") + .value_parser(parser) + .help("Filter by status"), + ); + let mut buf = Vec::new(); + cmd.clone() + .write_long_help(&mut buf) + .expect("clap should render long help"); + String::from_utf8(buf).expect("clap long help is utf-8") + } + + #[test] + fn test_build_enum_value_parser_no_fern_enum_uses_wire_values() { + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["a".to_string(), "b".to_string()]), + ..Default::default() + }; + let help = render_arg_long_help(¶m); + assert!( + help.contains("possible values") && help.contains("a") && help.contains("b"), + "wire values must be listed in long help when no fern_enum is set; got:\n{help}", + ); + } + + #[test] + fn test_build_enum_value_parser_renders_display_name_and_per_value_help() { + let param = param_with_fern_enum( + &["all", "managed", "external"], + &[ + ("all", Some("All"), Some("Every user.")), + ( + "managed", + Some("Managed"), + Some("Enterprise-managed users."), + ), + ("external", None, Some("External collaborators only.")), + ], + ); + let help = render_arg_long_help(¶m); + + // Display names are the rendered option labels in long help. + assert!( + help.contains("All") && help.contains("Managed"), + "display names must appear in long help, got:\n{help}", + ); + // The un-overridden entry still surfaces its wire value. + assert!( + help.contains("external"), + "wire value must appear when no display override is set, got:\n{help}", + ); + // Per-value descriptions land in long help. + assert!( + help.contains("Every user."), + "missing first description in:\n{help}" + ); + assert!( + help.contains("Enterprise-managed users."), + "missing second description in:\n{help}", + ); + assert!( + help.contains("External collaborators only."), + "missing third description in:\n{help}", + ); + } + + /// Both the display alias and the wire value must successfully parse + /// when `display_name` is set — this is the key affordance promised + /// by `x-fern-enum` for CLI users who only know one of the names. + #[test] + fn test_build_enum_value_parser_accepts_both_display_and_wire() { + let param = param_with_fern_enum( + &["all", "managed", "external"], + &[ + ("all", Some("All"), None), + ("managed", Some("Managed"), None), + ], + ); + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("status").long("status").value_parser(parser)); + + for input in ["All", "all", "Managed", "managed", "external"] { + let matches = cmd + .clone() + .try_get_matches_from(vec!["test", "--status", input]) + .unwrap_or_else(|e| panic!("input `{input}` should parse; got error: {e}")); + let parsed = matches.get_one::("status").unwrap(); + assert_eq!( + parsed, input, + "clap returns the user-typed value verbatim; display->wire mapping happens later", + ); + } + + // A bogus value still fails — guards against accidentally + // accepting arbitrary strings when fern_enum is set. + assert!( + cmd.clone() + .try_get_matches_from(vec!["test", "--status", "Bogus"]) + .is_err(), + "values not in the enum must still be rejected", + ); + } + + /// `name == wire` is a no-op: clap rejects an alias equal to the + /// canonical name, so the builder must dedupe instead of crashing. + /// Build the parser into a `Command` to confirm clap accepts it. + #[test] + fn test_build_enum_value_parser_skips_alias_when_display_equals_wire() { + let param = param_with_fern_enum( + &["managed"], + &[("managed", Some("managed"), Some("Same wire & display."))], + ); + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("status").long("status").value_parser(parser)); + let matches = cmd + .try_get_matches_from(vec!["test", "--status", "managed"]) + .expect("clap should accept a PossibleValue without a self-alias"); + assert_eq!(matches.get_one::("status").unwrap(), "managed"); + } + + #[test] + fn test_build_enum_value_parser_accepts_null_when_param_nullable() { + // A nullable enum field must accept the literal `null` alongside its + // wire values. The sentinel→Value::Null transform happens later in + // collect_params_from_flags; clap's job is just to admit the string. + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["red".to_string(), "blue".to_string()]), + nullable: true, + ..Default::default() + }; + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("color").long("color").value_parser(parser)); + + for input in ["red", "blue", "null"] { + cmd.clone() + .try_get_matches_from(vec!["test", "--color", input]) + .unwrap_or_else(|e| panic!("nullable enum should accept `{input}`; got: {e}")); + } + + assert!( + cmd.clone() + .try_get_matches_from(vec!["test", "--color", "purple"]) + .is_err(), + "values outside the enum (and not the null sentinel) must still be rejected", + ); + } + + #[test] + fn test_build_enum_value_parser_rejects_null_when_non_nullable() { + // Regression guard: a non-nullable enum field must NOT accept "null". + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["red".to_string(), "blue".to_string()]), + nullable: false, + ..Default::default() + }; + let parser = build_enum_value_parser(param.enum_values.as_ref().unwrap(), ¶m); + let cmd = Command::new("test").arg(Arg::new("color").long("color").value_parser(parser)); + assert!( + cmd.try_get_matches_from(vec!["test", "--color", "null"]) + .is_err(), + "non-nullable enum must reject `null` (closed set)", + ); + } + + #[test] + fn test_build_enum_value_parser_nullable_lists_null_in_help() { + // The clap-rendered help must include `null` in the [possible values] + // listing so users can discover the sentinel from `--help` alone. + let param = MethodParameter { + param_type: Some("string".to_string()), + enum_values: Some(vec!["red".to_string(), "blue".to_string()]), + nullable: true, + ..Default::default() + }; + let help = render_arg_long_help(¶m); + assert!( + help.contains("null"), + "nullable enum's long help must list `null` as a possible value, got:\n{help}", + ); + } + + #[test] + fn test_json_help_text_rest_method() { + use crate::openapi::discovery::SchemaRef; + + // REST method with a request body → --json should describe the request body. + let mut rest_methods = HashMap::new(); + rest_methods.insert( + "create".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Thing".to_string()), + parameter_name: None, + }), + ..Default::default() + }, + ); + let mut rest_resources = HashMap::new(); + rest_resources.insert( + "things".to_string(), + RestResource { + methods: rest_methods, + resources: HashMap::new(), + }, + ); + let rest_doc = RestDescription { + name: "rest-cli".to_string(), + resources: rest_resources, + ..Default::default() + }; + let rest_cmd = build_cli(&rest_doc); + let rest_json = rest_cmd + .find_subcommand("things") + .and_then(|c| c.find_subcommand("create")) + .and_then(|c| c.get_arguments().find(|a| a.get_id() == "json")) + .expect("REST --json arg missing"); + let rest_help = rest_json + .get_help() + .map(|s| s.to_string()) + .unwrap_or_default(); + assert!( + rest_help.contains("request body"), + "REST --json help should describe the request body, got: {rest_help}", + ); + } + + // ------------------------------------------------------------------ + // filter_doc_by_audiences — fern parity + // ------------------------------------------------------------------ + + /// Build a doc with four operations covering every audience-tag + /// shape used by the fixture spec: one tagged, one with a + /// distinct tag, one untagged, and one multi-tagged. Used by all + /// `filter_doc_by_audiences` tests below. + fn doc_with_audiences() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "public-only".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/p".to_string(), + audiences: vec!["public".to_string()], + ..Default::default() + }, + ); + methods.insert( + "internal-only".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/i".to_string(), + audiences: vec!["internal".to_string()], + ..Default::default() + }, + ); + methods.insert( + "untagged".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/u".to_string(), + audiences: vec![], + ..Default::default() + }, + ); + methods.insert( + "multi-tagged".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/m".to_string(), + audiences: vec!["public".to_string(), "internal".to_string()], + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "audiences".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "fixture".to_string(), + resources, + ..Default::default() + } + } + + fn method_names(doc: &RestDescription, group: &str) -> Vec { + let mut names: Vec = doc + .resources + .get(group) + .map(|r| r.methods.keys().cloned().collect()) + .unwrap_or_default(); + names.sort(); + names + } + + /// Empty audience filter is a no-op. Mirrors fern's + /// `audiences.length > 0 && ...` guard in + /// `openapi-ir-parser/generateIr.ts:141` — when no audiences are + /// active, every operation passes through. + #[test] + fn test_filter_doc_by_audiences_empty_is_noop() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &[]); + assert_eq!( + method_names(&doc, "audiences"), + vec!["internal-only", "multi-tagged", "public-only", "untagged"], + ); + } + + /// Single audience keeps only operations whose `x-fern-audiences` + /// contains that tag. Untagged operations are dropped — matches + /// fern's `operationAudiences.includes(...)` over an empty array + /// always evaluating false. + #[test] + fn test_filter_doc_by_audiences_single_keeps_matching_only() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["public".to_string()]); + assert_eq!( + method_names(&doc, "audiences"), + vec!["multi-tagged", "public-only"], + ); + } + + /// Multiple active audiences union the matches (OR, not AND). + /// Mirrors fern's `audiences.some(a => operationAudiences.includes(a))`: + /// any one match keeps the operation. + #[test] + fn test_filter_doc_by_audiences_multiple_unions_matches() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences( + &mut doc, + &["public".to_string(), "internal".to_string()], + ); + assert_eq!( + method_names(&doc, "audiences"), + vec!["internal-only", "multi-tagged", "public-only"], + ); + } + + /// An audience that no operation declares prunes every operation + /// and then collapses the now-empty resource group. Matches fern's + /// behavior: a preset audience with no matches in the spec yields + /// an empty IR. + #[test] + fn test_filter_doc_by_audiences_nonexistent_prunes_empty_resources() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["nonexistent".to_string()]); + assert!( + doc.resources.is_empty(), + "filtering all ops out of a group should also prune the empty group itself: \ + {:?}", + doc.resources + ); + } + + /// Audience names are compared as opaque strings — case sensitive, + /// no normalization — to match fern's treatment of audience tags + /// as identifiers. `Public` and `public` do NOT match. + #[test] + fn test_filter_doc_by_audiences_is_case_sensitive() { + let mut doc = doc_with_audiences(); + filter_doc_by_audiences(&mut doc, &["Public".to_string()]); + assert!( + doc.resources.is_empty(), + "case-mismatched audience should match nothing" + ); + } + + /// Nested resources are walked recursively, and an outer resource + /// with only an empty child is itself collapsed. Guards against the + /// recursive prune accidentally leaving orphan parent groups in the + /// command tree. + #[test] + fn test_filter_doc_by_audiences_recurses_into_nested_resources() { + let mut inner_methods = HashMap::new(); + inner_methods.insert( + "ping".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/p".to_string(), + audiences: vec!["public".to_string()], + ..Default::default() + }, + ); + let mut inner_resources = HashMap::new(); + inner_resources.insert( + "v2".to_string(), + RestResource { + methods: inner_methods, + resources: HashMap::new(), + }, + ); + let outer = RestResource { + methods: HashMap::new(), + resources: inner_resources, + }; + let mut resources = HashMap::new(); + resources.insert("audiences".to_string(), outer); + let mut doc = RestDescription { + name: "fixture".to_string(), + resources, + ..Default::default() + }; + + filter_doc_by_audiences(&mut doc, &["public".to_string()]); + let nested = &doc.resources["audiences"].resources["v2"]; + assert!(nested.methods.contains_key("ping")); + + filter_doc_by_audiences(&mut doc, &["nonexistent".to_string()]); + assert!( + doc.resources.is_empty(), + "nested empty groups should propagate up and prune the outer" + ); + } + + // ------------------------------------------------------------------ + // x-fern-groups (FER-9864 P3): document-root metadata that + // re-labels `x-fern-sdk-group-name` group subcommands for the + // help surface. No tree restructuring; the `--help` `about` / + // `long_about` lines for the group's clap Command change when a + // matching entry exists, otherwise the legacy `Operations on + // ''` fallback applies (preserving prior behavior). + // ------------------------------------------------------------------ + + fn make_doc_with_things_resource() -> RestDescription { + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + } + } + + /// Precondition for the rest of the suite: without `x-fern-groups` + /// metadata, the group's clap Command uses the legacy + /// `Operations on ''` about text. This is the fallback the + /// "missing metadata" integration case verifies end-to-end. + #[test] + fn test_group_about_falls_back_to_legacy_label_when_no_metadata() { + let doc = make_doc_with_things_resource(); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'things'", + ); + assert!(things.get_long_about().is_none()); + } + + /// A matching `x-fern-groups` entry with `summary` overrides the + /// fallback `Operations on ''` label on the clap `about()` + /// line. The `summary` text surfaces in both the parent's command + /// table (next to the subcommand name) and in `--help` for the + /// group itself. + #[test] + fn test_group_summary_overrides_about_text() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things Operations".to_string()), + description: None, + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things Operations", + ); + // No `description` → no long_about override; clap will fall + // back to `about` for `--help`. + assert!(things.get_long_about().is_none()); + } + + /// `description` populates `long_about()` so `--help` shows the + /// detailed prose for the group. Setting `description` alone + /// (without `summary`) keeps the legacy short label — fern's IR + /// allows either field to be present without the other and we + /// preserve that asymmetry. + #[test] + fn test_group_description_sets_long_about_only() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: None, + description: Some("Long-form prose about things.".to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'things'", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Long-form prose about things.", + ); + } + + /// Both fields set: `summary` becomes `about`, `description` + /// becomes `long_about`. This is the canonical case the + /// integration test exercises against `--help`. + #[test] + fn test_group_summary_and_description_populate_both_about_fields() { + let mut doc = make_doc_with_things_resource(); + doc.groups.insert( + "things".to_string(), + SdkGroupInfo { + summary: Some("Things Operations".to_string()), + description: Some("Long-form prose about things.".to_string()), + }, + ); + let cmd = build_cli(&doc); + let things = cmd + .find_subcommand("things") + .expect("things subcommand missing"); + assert_eq!( + things.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Things Operations", + ); + assert_eq!( + things + .get_long_about() + .map(|s| s.to_string()) + .unwrap_or_default(), + "Long-form prose about things.", + ); + } + + /// Integration case (a) — matched group: spec carries + /// `x-fern-groups: { foo: { summary: "Foo Operations" } }`, two + /// operations are tagged `x-fern-sdk-group-name: foo`, and one is + /// untagged. Verifies the `foo` subcommand's `about()` line is + /// `Foo Operations` (driven by `summary`) and that the untagged + /// op lands on its tag-derived sibling group with the legacy + /// fallback label. + /// + /// Drives the parser end-to-end so the full path + /// (YAML → `OpenApiSpec` → `RestDescription` → `Command`) is + /// covered. + #[test] + fn test_x_fern_groups_summary_drives_about_for_matched_group() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + foo: + summary: Foo Operations + description: Operations on foo resources. +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } + /foo/create: + post: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: create + operationId: foo_create + responses: + "200": { description: ok } + /misc: + get: + tags: [Misc] + x-fern-sdk-method-name: list + operationId: misc_list + responses: + "200": { description: ok } +"#; + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + let cmd = build_cli(&doc); + + // Matched group: `summary` wins over the legacy fallback. + let foo = cmd + .find_subcommand("foo") + .expect("foo subcommand should exist"); + assert_eq!( + foo.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Foo Operations", + ); + assert_eq!( + foo.get_long_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on foo resources.", + ); + // Group children are still present — `x-fern-groups` does not + // restructure the clap tree. + assert!(foo.find_subcommand("list").is_some()); + assert!(foo.find_subcommand("create").is_some()); + } + + /// Integration case (b) — missing-metadata fallback: an operation + /// carries `x-fern-sdk-group-name: [bar]` but the spec has no + /// matching `x-fern-groups: bar` entry. The CLI must build + /// without error and the `bar` subcommand keeps the legacy + /// `Operations on 'bar'` about line. Verifies no crash and clean + /// fallback when only one side of the pair is present. + #[test] + fn test_x_fern_groups_missing_entry_falls_back_to_default_label() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /bar/list: + get: + x-fern-sdk-group-name: [bar] + x-fern-sdk-method-name: list + operationId: bar_list + responses: + "200": { description: ok } +"#; + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + assert!( + doc.groups.is_empty(), + "no x-fern-groups → groups map should be empty", + ); + let cmd = build_cli(&doc); + let bar = cmd + .find_subcommand("bar") + .expect("bar subcommand should exist"); + assert_eq!( + bar.get_about().map(|s| s.to_string()).unwrap_or_default(), + "Operations on 'bar'", + ); + assert!(bar.get_long_about().is_none()); + assert!(bar.find_subcommand("list").is_some()); + } + + /// `x-fern-groups` is purely metadata for rendering — adding it + /// must NOT change which subcommands exist, their nesting, or + /// their leaf method commands. This guards the hard constraint + /// that the clap tree structure stays untouched. + #[test] + fn test_x_fern_groups_does_not_restructure_clap_tree() { + let yaml_without = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } +"#; + let yaml_with = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + foo: + summary: Foo Operations +paths: + /foo/list: + get: + x-fern-sdk-group-name: [foo] + x-fern-sdk-method-name: list + operationId: foo_list + responses: + "200": { description: ok } +"#; + let collect_tree = |yaml: &str| -> Vec { + let doc = crate::openapi::load_openapi_spec(yaml, "test-cli").unwrap(); + let cmd = build_cli(&doc); + let mut out = Vec::new(); + for sub in cmd.get_subcommands() { + for leaf in sub.get_subcommands() { + out.push(format!("{}.{}", sub.get_name(), leaf.get_name())); + } + } + out.sort(); + out + }; + assert_eq!(collect_tree(yaml_without), collect_tree(yaml_with)); + } + + #[test] + fn test_multipart_field_builtin_collision_skipped() { + use crate::openapi::discovery::MultipartField; + + let mut methods = HashMap::new(); + methods.insert( + "upload".to_string(), + RestMethod { + http_method: "POST".to_string(), + path: "/uploads".to_string(), + multipart_fields: vec![ + MultipartField { + wire_name: "format".to_string(), + is_file: false, + description: Some("Collides with builtin --format".to_string()), + required: false, + content_type: None, + }, + MultipartField { + wire_name: "output".to_string(), + is_file: false, + description: Some("Collides with builtin --output".to_string()), + required: false, + content_type: None, + }, + MultipartField { + wire_name: "file".to_string(), + is_file: true, + description: Some("No collision".to_string()), + required: true, + content_type: Some("application/octet-stream".to_string()), + }, + ], + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "uploads".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "test-cli".to_string(), + resources, + ..Default::default() + }; + + // Must not panic from duplicate arg names. + let cmd = build_cli(&doc); + let uploads_cmd = cmd + .find_subcommand("uploads") + .expect("uploads resource missing"); + let upload_cmd = uploads_cmd + .find_subcommand("upload") + .expect("upload method missing"); + + let arg_ids: Vec = upload_cmd + .get_arguments() + .map(|a| a.get_id().to_string()) + .collect(); + + // "file" should be present (no collision). + assert!( + arg_ids.contains(&"file".to_string()), + "non-colliding multipart field 'file' should be present, got: {arg_ids:?}", + ); + // "format" and "output" appear exactly once (from the global builtins). + let format_count = arg_ids.iter().filter(|a| *a == "format").count(); + assert!( + format_count <= 1, + "multipart 'format' should be skipped to avoid duplicate; found {format_count} in {arg_ids:?}", + ); + let output_count = arg_ids.iter().filter(|a| *a == "output").count(); + assert!( + output_count <= 1, + "multipart 'output' should be skipped to avoid duplicate; found {output_count} in {arg_ids:?}", + ); + } +} diff --git a/src/openapi/discovery.rs b/src/openapi/discovery.rs new file mode 100644 index 0000000..e2dd682 --- /dev/null +++ b/src/openapi/discovery.rs @@ -0,0 +1,1279 @@ +//! Internal OpenAPI Representation +//! +//! Data structures representing an OpenAPI API's resources, methods, parameters, and schemas. +//! These structs serve as the internal representation that the command builder and +//! executor consume. + +use std::collections::HashMap; + +use serde::Deserialize; + +/// Top-level API description. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestDescription { + pub name: String, + pub version: String, + pub title: Option, + pub description: Option, + pub root_url: String, + /// All top-level `servers:` entries from the spec, in declaration order. + /// `root_url` is the URL of the first entry (kept for backwards + /// compatibility with existing call sites). Servers with a resolved + /// `name` (from `x-name`, falling back to `x-fern-server-name`) drive + /// the global `--server ` flag — when the spec has at least one + /// named server, the flag is exposed and its allowed values are the + /// top-level named server names. Unnamed entries are still preserved + /// here so the order matches the spec; helpers like + /// [`RestDescription::named_servers`] filter them out for the help + /// surface. + #[serde(default, skip)] + pub servers: Vec, + #[serde(default)] + pub service_path: String, + pub base_url: Option, + /// Common prefix prepended to every operation path at request time — + /// sourced from the spec-level `x-fern-base-path` extension. Used to + /// declare a shared base like `/v1` or `/api/public` once on the spec + /// instead of duplicating it on every path. Mirrors the upstream + /// Fern OpenAPI importer: + /// + /// + /// At request time the executor inserts this between the server URL + /// and the operation path with exactly one slash between segments. + /// An empty string is treated the same as `None`. + pub base_path: Option, + #[serde(default)] + pub schemas: HashMap, + #[serde(default)] + pub resources: HashMap, + #[serde(default)] + pub parameters: HashMap, + pub auth: Option, + /// Auth schemes declared in `components.securitySchemes`. The key is the + /// scheme name as it appears in the spec — that name is what + /// per-operation `security` requirements reference, and what + /// `CliApp::auth_scheme(name, source)` binds a credential source to. + #[serde(default)] + pub security_schemes: HashMap, + /// Query parameter name for pagination tokens (default: "pageToken"). + #[serde(default)] + pub pagination_token_query_param: Option, + /// Dotted path to next page token in response JSON (default: "nextPageToken"). + /// Supports nested paths like "pagination.next_page_token". + #[serde(default)] + pub pagination_token_response_path: Option, + /// Idempotency header definitions parsed from the spec-root + /// [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) + /// extension. Empty when the extension is absent. + /// + /// Each entry describes a header that operations marked with + /// `x-fern-idempotent: true` accept. The parser materializes one CLI + /// flag per header on every idempotent operation; non-idempotent + /// operations are unaffected and never send these headers. + #[serde(default, skip)] + pub idempotency_headers: Vec, + /// Constructor-style globals declared by the spec's top-level + /// `x-fern-sdk-variables` extension. Each entry surfaces as a global + /// CLI flag (kebab-cased) with an env-var fallback + /// (SCREAMING_SNAKE_CASE of the variable name) and replaces the + /// corresponding `{varName}` placeholder in path templates of + /// operations whose path parameter carries `x-fern-sdk-variable`. + /// + /// See . + #[serde(default, skip)] + pub sdk_variables: Vec, + /// Spec-root [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) + /// extension. Inherited by every operation that omits its own + /// `x-fern-retries` block, or that sets `x-fern-retries: true` to + /// opt in to the spec-root defaults. A per-op object merges over + /// this baseline; a per-op `false` (or `{ disabled: true }`) + /// disables retries on that operation regardless of root. + #[serde(default, skip)] + pub retries: Option, + /// Global parameter definitions parsed from the spec-root + /// `x-fern-global-parameters` extension. Generalizes + /// `x-fern-global-headers` to support header, query, body, and path + /// locations. Each entry surfaces as a global CLI flag and is + /// injected into outgoing requests at the configured location. + #[serde(default, skip)] + pub global_parameters: Vec, + /// Global header definitions parsed from the spec-root + /// [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) + /// extension. Empty when the extension is absent. + /// + /// Each entry surfaces as a global CLI flag at the root of the + /// command tree with an env-var fallback and (when configured) a + /// baked-in default value. The resolved value is stamped on every + /// outgoing request as the named HTTP header — per-operation + /// parameters with the same wire-name win. + #[serde(default, skip)] + pub global_headers: Vec, + /// Top-level group metadata sourced from the document-root + /// [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) + /// extension. Mirrors the upstream Fern OpenAPI importer's + /// [`SdkGroupInfo`](https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernGroups.ts) + /// record (`{ summary?, description? }`). + /// + /// Keys are kebab-cased to match the resource keys built from + /// `x-fern-sdk-group-name`, so a `foo` entry binds to the `foo` + /// subcommand resource and a `myGroup` entry binds to the + /// `my-group` resource. Values are purely metadata for `--help` + /// rendering — `x-fern-groups` does NOT restructure the clap tree, + /// matching fern's semantics where the extension only annotates + /// existing groups for documentation. + #[serde(default, skip)] + pub groups: HashMap, +} + +/// Metadata for a single group declared via the spec-root +/// [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) +/// extension. +/// +/// Mirrors fern's `SdkGroupInfo` IR type (both fields optional): +/// +/// (`SdkGroupInfo { summary: optional, description: optional }`). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SdkGroupInfo { + /// Short human-friendly label for the group. When present, replaces + /// the default `Operations on ''` text used as the clap + /// subcommand's `about()` line. + pub summary: Option, + /// Longer prose description of the group. When present, used as the + /// clap subcommand's `long_about()` so `--help` shows the full text + /// for the group. + pub description: Option, +} + +/// A single global header definition from the spec-root +/// [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) +/// extension. Mirrors the [`GlobalHeader`] IR type emitted by the +/// upstream Fern OpenAPI importer. +/// +/// The CLI uses `name` (if set) to derive the kebab-cased flag name and +/// `header` as the on-the-wire HTTP header name. When `env` is set the +/// flag accepts the value from that environment variable as a fallback, +/// and `default` is used when neither the flag nor the env var is +/// supplied. Operations may opt out of sending the header by declaring +/// a same-named per-operation parameter, which takes precedence. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct GlobalHeader { + /// HTTP header name sent on the wire (e.g. `X-API-Version`). + pub header: String, + /// Optional SDK/CLI parameter name. When set, used as the basis for + /// the kebab-cased CLI flag name; otherwise the flag derives from + /// `header`. + pub name: Option, + /// When `false` (the default), the CLI flag is required — every + /// outgoing request must carry a value. When `true`, the header is + /// omitted from requests where no value resolved. + pub optional: bool, + /// Optional environment variable that provides a fallback value for + /// the generated flag. + pub env: Option, + /// Optional baked-in default value applied when neither the flag + /// nor the environment variable is supplied. Mirrors the upstream + /// `x-fern-default` shape — only the value is preserved; the + /// schema type is informational. + pub default: Option, +} + +/// Where a global parameter value is injected on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GlobalParameterLocation { + /// HTTP header (e.g. `X-Custom-Header`). + Header, + /// URL query parameter (e.g. `?language=en`). + Query, + /// Nested JSON request body path (e.g. `config.currency`). + Body, + /// URL path segment (e.g. `{regionId}`). + Path, +} + +/// Controls which operations receive the global parameter. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GlobalParameterApplyMode { + /// Inject on every operation (unless a per-operation parameter with the + /// same wire name overrides it). + #[default] + Auto, + /// Only inject on operations that explicitly list the parameter in + /// `x-fern-global-parameter`. + Explicit, +} + +/// A single global parameter definition from the spec-root +/// [`x-fern-global-parameters`] extension. Generalizes +/// [`GlobalHeader`] to support header, query, body, and path locations. +/// +/// Each entry surfaces as a global CLI flag at the root of the command +/// tree with an env-var fallback and (when configured) a baked-in default +/// value. The resolved value is injected into outgoing requests at the +/// location specified by [`GlobalParameter::location`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlobalParameter { + /// Canonical parameter name — used as the basis for the kebab-cased + /// CLI flag name (unless `parameter_name` overrides it). + pub name: String, + /// Where the resolved value is injected on the wire. + pub location: GlobalParameterLocation, + /// Wire-level target. For headers this is the header name + /// (e.g. `X-Max-Retries`); for query it's the query parameter name; + /// for body it's a dotted JSON path (e.g. `config.currency`); for + /// path it's the path template variable name (e.g. `regionId`). + /// Defaults to `name` when absent in the extension. + pub target: String, + /// Optional environment variable that provides a fallback value. + pub env: Option, + /// Optional baked-in default value applied when neither the flag + /// nor the environment variable is supplied. + pub default: Option, + /// When `false` (the default), the CLI flag is required — every + /// outgoing request must carry a value. When `true`, the parameter + /// is omitted from requests where no value resolved. + pub optional: bool, + /// Controls whether the parameter is injected on all operations + /// or only on those that explicitly opt in. + pub apply: GlobalParameterApplyMode, + /// Optional flag name override for the CLI surface + /// (e.g. `maxRetries` → `--max-retries`). + pub parameter_name: Option, + /// One-line help text for the `--help` output. + pub docs: Option, +} + +/// A single idempotency-header definition from the spec-root +/// [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) +/// extension. Mirrors the [`IdempotencyHeader`] IR type emitted by the +/// upstream Fern OpenAPI importer. +/// +/// The CLI uses `name` (if set) to derive the kebab-cased flag name and +/// `header` as the on-the-wire HTTP header name. When `env` is set the +/// flag accepts the value from that environment variable as a fallback. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct IdempotencyHeader { + /// HTTP header name sent on the wire (e.g. `Idempotency-Key`). + pub header: String, + /// Optional SDK/CLI parameter name. When set, used as the basis for + /// the kebab-cased CLI flag name; otherwise the flag derives from + /// `header`. + pub name: Option, + /// Optional environment variable that provides a default value for + /// the generated flag. Generators can override this at build time via + /// [`crate::openapi::app::CliApp::idempotency_header_env`]. + pub env: Option, +} + +/// A spec-level `x-fern-sdk-variables` entry. Modeled as a constructor-style +/// global that operations can bind path parameters to via +/// `x-fern-sdk-variable: `. +/// +/// Fern's TS/Python/Java SDKs only support `type: string` here today, so the +/// parser warns and skips non-string entries (mirroring the upstream +/// importer's `Variable has unsupported schema` rejection but without +/// failing the whole spec load — the CLI is intentionally permissive). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SdkVariable { + /// Variable name as it appears in path templates (e.g. `gardenId`). + pub name: String, + /// Lowered OpenAPI primitive type. Always `string` today; carried so a + /// future generator change can specialize the global flag's `value_name`. + pub ty: String, + /// One-line `--help` description (from the variable schema's + /// `description:` field). + pub description: Option, +} + +/// How the request body should be serialized on the wire. +/// +/// Determines the `Content-Type` header and payload encoding strategy. +/// Modeled as an enum so future body formats (multipart/form-data, etc.) +/// can be added as variants without boolean proliferation. +/// +/// ## OpenAPI form encoding options (future work) +/// +/// For `FormUrlEncoded`, the OAS 3.x `encoding` map supports per-property +/// overrides: `style` (form | spaceDelimited | pipeDelimited | deepObject), +/// `explode` (true | false), `contentType`, and `allowReserved`. These are +/// not yet parsed or acted upon — the current implementation uses the +/// defaults (`style: form`, `explode: true`) which produce repeated keys +/// for arrays (e.g. `tag=a&tag=b`). When a real consumer needs non-default +/// serialization, these fields should be added to the `FormUrlEncoded` +/// variant as a `HashMap`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BodyEncoding { + /// `application/json` — the default encoding for request bodies. + #[default] + Json, + /// `application/x-www-form-urlencoded` — flat key=value pairs. + /// + /// Current behavior: top-level keys emitted as-is, arrays repeat the + /// key (`tag=a&tag=b`), nested objects are JSON-encoded as values. + FormUrlEncoded, + // Future variants: + // MultipartFormData { encoding: HashMap }, +} + +impl BodyEncoding { + /// The `Content-Type` header value for this encoding. + pub fn content_type(&self) -> &'static str { + match self { + Self::Json => "application/json", + Self::FormUrlEncoded => "application/x-www-form-urlencoded", + } + } + + /// Returns `true` when the encoding is form-urlencoded. + pub fn is_form(&self) -> bool { + matches!(self, Self::FormUrlEncoded) + } +} + +/// Lifecycle/availability of an operation or parameter, sourced from the +/// `x-fern-availability` extension on the OpenAPI element. Mirrors the +/// canonical Fern values documented at +/// . +/// +/// `Deprecated` is also reached when an operation has no +/// `x-fern-availability` extension but does carry the OpenAPI +/// `deprecated: true` flag — in that case the parser surfaces +/// `Deprecated` (see `parser.rs`). +/// +/// NOTE: deliberate divergence from the Fern OpenAPI IR importer +/// (`packages/cli/api-importers/openapi/openapi-ir-parser`): the importer +/// collapses `pre-release` into [`Availability::Beta`] in the IR, since +/// downstream SDK generators only need to know "is this stable" / +/// "is this pre-stable" / "is this gone". The cli-sdk parser keeps +/// `PreRelease` as its own variant so the help-output badge can +/// differentiate `[PRE-RELEASE]` from `[BETA]` — both are documented +/// values in the [Fern reference], and treating them as the same loses +/// signal at the CLI surface where the user is reading help text. +/// +/// [Fern reference]: https://buildwithfern.com/learn/api-definitions/openapi/extensions/availability +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Availability { + /// Pre-stable, in active development. Tagged `[ALPHA]` in help output. + Alpha, + /// Pre-release / preview API. Tagged `[PRE-RELEASE]` in help output. + /// Distinct from [`Availability::Beta`] in cli-sdk; see enum docs. + PreRelease, + /// Public beta. Tagged `[BETA]` in help output. + Beta, + /// Public preview. Tagged `[PREVIEW]` in help output. + Preview, + /// Generally available. No badge — this is the implicit default when + /// `x-fern-availability` is absent. Accepts `ga` as an alias (matches + /// the Fern OpenAPI importer). + #[serde(alias = "ga")] + GenerallyAvailable, + /// Deprecated; still callable but discouraged. Tagged `[DEPRECATED]` + /// in help output. Also inferred from OpenAPI `deprecated: true`. + Deprecated, + /// Legacy / sunset API. Tagged `[LEGACY]` in help output. + Legacy, +} + +impl Availability { + /// Returns the badge label used in CLI help output for this + /// availability, or `None` for [`Availability::GenerallyAvailable`] + /// (the implicit default — no badge). + pub fn badge(self) -> Option<&'static str> { + match self { + Availability::Alpha => Some("[ALPHA]"), + Availability::PreRelease => Some("[PRE-RELEASE]"), + Availability::Beta => Some("[BETA]"), + Availability::Preview => Some("[PREVIEW]"), + Availability::GenerallyAvailable => None, + Availability::Deprecated => Some("[DEPRECATED]"), + Availability::Legacy => Some("[LEGACY]"), + } + } + + /// Lowercase wire identifier matching the canonical Fern spelling + /// (`alpha`, `beta`, `pre-release`, `preview`, `generally-available`, + /// `deprecated`, `legacy`). Used for the `availability` field + /// surfaced in `--schema` output. + pub fn as_str(self) -> &'static str { + match self { + Availability::Alpha => "alpha", + Availability::PreRelease => "pre-release", + Availability::Beta => "beta", + Availability::Preview => "preview", + Availability::GenerallyAvailable => "generally-available", + Availability::Deprecated => "deprecated", + Availability::Legacy => "legacy", + } + } +} + +/// A single auth scheme declared in `components.securitySchemes`. Mirrors +/// the OpenAPI 3 Security Scheme Object, lowered to just the bits we +/// dispatch on at runtime. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub enum SecurityScheme { + /// `type: http, scheme: bearer` → `Authorization: Bearer `. + HttpBearer, + /// `type: http, scheme: basic` → `Authorization: Basic `. + HttpBasic, + /// `type: apiKey, in: header, name: X-Api-Key` → `: `. + ApiKeyHeader { name: String }, + /// `type: apiKey, in: query, name: api_key` — represented for parsing + /// fidelity. The CLI doesn't attach query-key auth itself today; + /// `RoutingAuthProvider` will skip a requirement that names this scheme. + ApiKeyQuery { name: String }, + /// `type: oauth2`. The CLI treats these the same as `HttpBearer` at + /// request time — the user supplies an already-issued access token via + /// env var. Token refresh is out of scope. + OAuth2, + /// Anything we don't model (mTLS, openIdConnect, etc.). Recorded so the + /// scheme name is still routable if a separate provider is bound to it + /// programmatically. + Other(String), +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct AuthDescription { + pub oauth2: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct OAuth2Description { + pub scopes: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct ScopeDescription { + pub description: Option, +} + +/// A resource which can contain methods and nested sub-resources. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct RestResource { + #[serde(default)] + pub methods: HashMap, + #[serde(default)] + pub resources: HashMap, +} + +/// One entry from an OpenAPI `servers:` array (top-level or per-operation), +/// lowered into the internal representation. +/// +/// `name` is populated from the Fern extensions `x-name` (v1, the +/// legacy alias) or `x-fern-server-name` (v2, the canonical Fern +/// spelling). When both are present on the same server entry, v1 wins +/// to mirror fern's `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` +/// first-match-wins semantics in +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. +/// Unnamed servers (no `x-fern-server-name` and no `x-name`) carry +/// `None`; they still participate in the default-URL chain (first +/// server wins) but are not selectable via the global `--server ` +/// flag. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Server { + /// Server URL as it appears in the spec (may contain `{variable}` + /// placeholders that are substituted later by [`CliApp::server_var`]). + pub url: String, + /// Resolved server name from `x-name` (v1 legacy alias, preferred to + /// mirror fern) or `x-fern-server-name` (v2 canonical Fern spelling). + /// `None` for unnamed entries. + pub name: Option, + /// Optional human-readable description from the spec — surfaced in + /// `--help` next to the server URL. + pub description: Option, +} + +impl RestDescription { + /// Returns the top-level servers that have a resolved name, paired + /// with the resolved name itself, in declaration order. Drives the + /// global `--server ` flag's allowed values and the + /// help-section listing. + /// + /// Yielding `(name, server)` tuples lets callers avoid re-checking + /// `server.name.is_some()` after the filter — the name is right + /// there, statically guaranteed to be non-empty (see + /// [`OpenApiServer::resolved_name`] in the parser, which trims and + /// drops empty strings at the source). + pub fn named_servers(&self) -> impl Iterator { + self.servers + .iter() + .filter_map(|s| s.name.as_deref().map(|n| (n, s))) + } +} + +/// Default total attempts (initial + retries) when retries are enabled. +/// +/// 4 total attempts = 3 retries. Matches the fern Python/TypeScript +/// runtime SDKs. The spec author can override this with +/// `x-fern-retries: { max_attempts: N }`. +/// +/// This was raised from 2 (FER-10521) to align with the cross-SDK +/// default and the expectation that transient 5xx / 429 / network +/// failures benefit from multiple retry attempts. CLIs that are embedded in +/// long-running applications where the latency of an extra retry is +/// acceptable. The CLI is interactive — a 3-second backoff before the +/// final failure feels broken. +pub const DEFAULT_RETRY_MAX_ATTEMPTS: u32 = 4; + +/// Default exponential-backoff base delay in milliseconds. The wait before +/// retry N is `base * factor^N` (plus jitter). With +/// [`DEFAULT_RETRY_MAX_ATTEMPTS`] = 4 the delays are 500ms, 1s, 2s. +pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 500; + +/// Default exponential-backoff growth factor. +pub const DEFAULT_RETRY_FACTOR: f64 = 2.0; + +/// Default jitter fraction (`0.1` = ±10% of the computed delay). +pub const DEFAULT_RETRY_JITTER: f64 = 0.1; + +/// Resolved retry policy for an endpoint (or the spec-root default), +/// lowered from the [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) +/// extension. +/// +/// Mirrors the upstream Fern OpenAPI importer's tagged shape — the +/// canonical lever is `disabled: bool`, which the importer surfaces as +/// `RetriesConfiguration::Disabled(value)`. cli-sdk extends the same +/// extension with optional knobs that the runtime retry loop honors at +/// request time: `max_attempts`, `base_delay_ms`, `factor`, `jitter`. The +/// extra knobs are forward-compatible with the upstream importer — they +/// are simply ignored on the fern side until the IR carries them. +/// +/// Resolution precedence (handled by the parser): +/// - per-op block absent → inherit the spec-root block (or `None` if also absent) +/// - per-op `true` → spec-root config, or all-defaults when root is absent +/// - per-op `false` (or `{ disabled: true }`) → disabled regardless of root +/// - per-op object → root values, overridden field-by-field by the op block +#[derive(Debug, Clone, PartialEq)] +pub struct RetriesConfig { + /// `true` (the default) means the executor's retry loop is active; + /// `false` disables retries for the operation. Maps to upstream + /// fern's `RetriesConfiguration::Disabled(value)` — the importer's + /// `disabled: true` lowers here as `enabled: false`. + pub enabled: bool, + /// Maximum total attempts (the initial request counts as attempt 1). + /// `max_attempts: 2` performs the request once and retries up to one + /// additional time. Validated as `>= 0` at parse time; a value of + /// `0` is treated identically to `disabled: true`. + pub max_attempts: u32, + /// Base delay between retries in milliseconds. The actual wait before + /// retry `n` (1-indexed) is `base_delay_ms * factor^(n-1)`, plus + /// optional jitter, capped by any server-supplied `Retry-After`. + pub base_delay_ms: u64, + /// Growth factor for exponential backoff (e.g. `2.0` doubles the + /// delay each retry). + pub factor: f64, + /// Jitter fraction in `[0.0, 1.0]`. A value of `0.1` adds a uniform + /// random offset in `±10%` of the computed delay so a stampede of + /// clients does not synchronize retries. + pub jitter: f64, +} + +impl Default for RetriesConfig { + fn default() -> Self { + Self { + enabled: true, + max_attempts: DEFAULT_RETRY_MAX_ATTEMPTS, + base_delay_ms: DEFAULT_RETRY_BASE_DELAY_MS, + factor: DEFAULT_RETRY_FACTOR, + jitter: DEFAULT_RETRY_JITTER, + } + } +} + +impl RetriesConfig { + /// Explicitly-disabled retry policy. Returned by the parser when the + /// spec sets `x-fern-retries: false` or `{ disabled: true }`. The + /// executor short-circuits on this variant — no retry loop, no + /// backoff, no Retry-After honor. + pub fn disabled() -> Self { + Self { + enabled: false, + max_attempts: 0, + base_delay_ms: 0, + factor: DEFAULT_RETRY_FACTOR, + jitter: 0.0, + } + } +} + +/// A single API method. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct RestMethod { + pub id: Option, + pub description: Option, + pub http_method: String, + pub path: String, + #[serde(default)] + pub parameters: HashMap, + #[serde(default)] + pub parameter_order: Vec, + pub request: Option, + pub response: Option, + #[serde(default)] + pub scopes: Vec, + pub flat_path: Option, + #[serde(default)] + pub supports_media_download: bool, + #[serde(default)] + pub supports_media_upload: bool, + pub media_upload: Option, + /// Per-operation base URL (populated from the spec's servers block during parsing). + /// When non-empty, takes priority over RestDescription.root_url in URL construction. + #[serde(default)] + pub root_url: String, + /// Per-operation `servers:` overrides (named or unnamed), in declaration + /// order. Empty when the operation has no `servers:` block (the + /// top-level [`RestDescription::servers`] applies instead). + /// + /// When non-empty, this list is the authoritative server set for the + /// operation — per-op `servers:` *replaces* the global default, it does + /// not augment it. The global `--server ` flag resolves against + /// this list first for operations that have it; if the flag value + /// doesn't match any per-op name, the executor falls back to + /// [`RestMethod::root_url`] (the first per-op server) so per-op routing + /// overrides are preserved. + #[serde(default, skip)] + pub servers: Vec, + /// Metadata for operations whose request body is raw binary (e.g. + /// `application/octet-stream`, `audio/mpeg`). When `Some`, the CLI exposes + /// a typed flag that streams a file as the body with the declared content + /// type. + #[serde(default)] + pub binary_request_body: Option, + /// Fields for a `multipart/form-data` request body. When non-empty, the + /// executor sends the request as multipart instead of JSON, and each + /// field surfaces as a per-operation CLI flag. Empty for non-multipart + /// operations. + #[serde(default, skip)] + pub multipart_fields: Vec, + /// How the request body should be serialized on the wire. + /// + /// Defaults to `BodyEncoding::Json`. The executor reads this to decide + /// the `Content-Type` header and encoding strategy. + #[serde(default)] + pub body_encoding: BodyEncoding, + /// Lowered OpenAPI security requirements: OR of ANDs. + /// + /// - `None` — operation didn't declare `security` and there was no + /// spec-level default to inherit. + /// - `Some(vec![])` — operation explicitly opts out (`security: []` in + /// the spec, or inherited explicit empty). + /// - `Some(vec![req1, req2, ...])` — satisfy any one requirement; each + /// requirement is an AND of scheme names with their requested scopes. + #[serde(default)] + pub security_requirements: Option>>>, + /// Resolved `x-fern-pagination` extension for this operation, after + /// applying root-level inheritance (per-op `x-fern-pagination: true` + /// inherits from the spec-root `x-fern-pagination` block). + /// + /// `None` means the operation has no explicit pagination config — the + /// executor falls back to the document-wide heuristic + /// (`pagination_token_query_param` + `pagination_token_response_path`). + #[serde(default, skip)] + pub pagination: Option, + /// Lowered `x-fern-availability` for the operation. `None` is the + /// implicit default (no badge). When the extension is absent but the + /// operation carries `deprecated: true`, the parser sets this to + /// `Some(Availability::Deprecated)` so the standard OpenAPI flag is + /// honored. + #[serde(default)] + pub availability: Option, + /// `true` when the operation is marked with + /// [`x-fern-idempotent: true`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotent). + /// Idempotent operations surface the spec-root idempotency-header + /// definitions as CLI flags; non-idempotent operations do not, and + /// never send idempotency headers on the wire. + #[serde(default)] + pub idempotent: bool, + /// Resolved `x-fern-sdk-return-value` extension — a dot-separated key + /// path through the JSON response body identifying the subvalue the + /// SDK / CLI should return to the caller. `None` (the implicit + /// default) means the executor prints the full response. + /// + /// Mirrors fern-api/fern's OpenAPI importer + /// (`FernOpenAPIExtension.RESPONSE_PROPERTY = "x-fern-sdk-return-value"`): + /// the value is consumed as a property path on the response body, + /// surfacing only the named subvalue. cli-sdk extends this to + /// support nested paths (e.g. `result.items`) at runtime — the + /// upstream Fern Definition path resolves a single object property, + /// but the CLI executor walks dotted paths the same way it does for + /// `x-fern-pagination`'s `next_*` / `results` paths. + #[serde(default)] + pub return_value: Option, + /// Resolved `x-fern-streaming` extension. `None` means the operation + /// returns a unary response and the executor reads/buffers the body + /// normally. `Some(_)` opts the executor into incremental + /// line-by-line response handling: each event/value is decoded as it + /// arrives and emitted to stdout (or buffered when `--no-stream` is + /// set). Mirrors the upstream Fern OpenAPI importer's + /// `getFernStreamingExtension` + /// (`fern-api/fern/.../extensions/getFernStreamingExtension.ts`). + /// + /// The runtime variant carries only what the executor needs at + /// request time: the wire format (SSE vs newline-delimited JSON) and + /// an optional terminator line. Upstream's `stream-condition` form + /// (which generates a streaming-and-unary endpoint pair in typed + /// SDKs) is parsed for parity but is not surfaced at the CLI + /// runtime — the CLI exposes one command per OpenAPI operation, so + /// the boolean stream-condition is treated as an unconditional + /// stream. + #[serde(default, skip)] + pub streaming: Option, + /// When `true`, the executor does NOT auto-generate an + /// `Idempotency-Key` header for this operation even though it uses + /// POST/PUT/PATCH. Set from `x-fern-cli-idempotency: false`. + #[serde(default, skip)] + pub no_auto_idempotency_key: bool, + /// Resolved `x-fern-retries` extension for this operation, after + /// applying root-level inheritance (per-op `true` adopts the spec-root + /// baseline; per-op object merges field-by-field over root). `None` + /// means the operation has no retry policy at all — the executor + /// runs the request exactly once. See [`RetriesConfig`] for the + /// precedence rules. + #[serde(default, skip)] + pub retries: Option, + /// Resolved [`x-fern-audiences`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/audiences) + /// tags for this operation, in declaration order with duplicates + /// preserved. Empty when the operation has no `x-fern-audiences` + /// extension. + /// + /// Used by the audience-filter pass at command-tree build time + /// (`commands::filter_doc_by_audiences`) to decide whether the + /// operation appears as a CLI subcommand. Untouched at request + /// time — the executor never inspects this field, matching fern's + /// "drop from IR" semantics rather than "skip at runtime". + /// + /// Mirrors fern-api/fern's OpenAPI importer + /// (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`): + /// `audiences: getExtension(operation, FernOpenAPIExtension.AUDIENCES) ?? []`. + /// + /// `skip` mirrors the convention used by peer internal-only + /// fields parsed from `x-fern-*` extensions (`retries`, + /// `streaming`, `pagination`) — set programmatically by the + /// parser, never round-tripped through `RestMethod` serialization. + #[serde(default, skip)] + pub audiences: Vec, + /// `true` when at least one `2xx` (or `2XX` wildcard) response declares + /// a content media type that is not JSON. Used at command-build time to + /// gate the `-o, --output PATH` flag: it's only meaningful for ops that + /// can return a binary body (audio, octet-stream, image, etc.) and + /// silently no-ops on pure-JSON ops, so the help surface hides it where + /// it would do nothing. Empty `responses` block → `false`. + #[serde(default, skip)] + pub has_binary_response: bool, + /// Parameter names from `x-fern-global-parameter` on this operation. + /// Only global parameters with `apply: explicit` that appear in this + /// list are injected on this operation. `apply: auto` parameters + /// ignore this field. + #[serde(default, skip)] + pub global_parameter_opt_ins: Vec, +} + +/// Per-operation pagination configuration, resolved from the +/// [`x-fern-pagination`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination) +/// OpenAPI extension. +/// +/// The five forms mirror `fern-api/fern`'s OpenAPI importer (see +/// `getPaginationExtension.ts`): +/// +/// - [`PaginationConfig::Cursor`] — token-based forward pagination +/// - [`PaginationConfig::Offset`] — numeric offset pagination +/// - [`PaginationConfig::Uri`] — server returns a fully-formed next URL +/// - [`PaginationConfig::Path`] — server returns a relative next path +/// - [`PaginationConfig::Custom`] — caller-driven; the executor stops after +/// one request (no automatic continuation) and exposes only the +/// `results` extraction +/// +/// `$request.` / `$response.` JSONPath prefixes are stripped during +/// parsing so values can be consumed directly: `cursor` / `offset` are the +/// request parameter name to populate on the next page, and `next_*`, +/// `results`, `has_next_page` are dotted JSON paths into the response. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PaginationConfig { + /// Cursor-style pagination — send the previous response's + /// `next_cursor` value as the request's `cursor` parameter on the next + /// page. Pagination stops when `next_cursor` is absent, null, or empty. + Cursor { + /// Request parameter name receiving the cursor token. + cursor: String, + /// Dotted JSON path in the response to the next cursor token. + next_cursor: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Offset-style pagination — send the running offset as the request's + /// `offset` parameter on each page. Pagination stops when + /// `has_next_page` is `false`, when the results array is empty, or + /// when the configured page limit is reached. + Offset { + /// Request parameter name receiving the offset value. + offset: String, + /// Dotted JSON path in the response to the results array. + results: String, + /// Optional request parameter name holding the page-size step. When + /// present, the offset advances by the step value the caller + /// supplied (e.g. `--params '{"limit": 50}'`). When absent, the + /// offset advances by the response page's results length. + step: Option, + /// Optional dotted JSON path in the response to a boolean + /// "more pages?" flag. + has_next_page: Option, + }, + /// URI pagination — the server returns a fully-formed URL for the + /// next page (e.g. `https://api.example.com/v1/things?cursor=abc`). + /// The executor uses that URL verbatim for the next request. + /// Pagination stops when the URL is absent, null, or empty. + Uri { + /// Dotted JSON path in the response to the next-page URL. + next_uri: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Path pagination — like [`PaginationConfig::Uri`] but the response + /// contains a relative path (e.g. `/v1/things?cursor=abc`) that the + /// executor resolves against the original request's base URL. + /// Pagination stops when the path is absent, null, or empty. + Path { + /// Dotted JSON path in the response to the next-page path. + next_path: String, + /// Dotted JSON path in the response to the results array. + results: String, + }, + /// Custom pagination — caller-driven. The CLI does not attempt + /// automatic continuation; it issues exactly one request and only + /// uses the `results` path for result extraction. + Custom { + /// Dotted JSON path in the response to the results array. + results: String, + }, +} + +/// Per-operation streaming configuration, resolved from the +/// [`x-fern-streaming`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/streaming) +/// OpenAPI extension. Mirrors the upstream Fern OpenAPI importer's +/// `getFernStreamingExtension` tagged union — the three wire formats +/// the runtime distinguishes (`sse`, `json`, `text`) line up with +/// Fern IR's `StreamingResponse` union (see +/// `packages/ir-sdk/fern/apis/ir-types-latest/definition/http.yml`). +/// +/// Recognized YAML shapes (parser side): +/// - `x-fern-streaming: true` → [`StreamingConfig::Json`] with no terminator +/// (matches upstream's boolean shorthand: `format: "json"`). +/// - `x-fern-streaming: false` → `None` (explicit opt-out). +/// - `x-fern-streaming: { format: sse }` → [`StreamingConfig::Sse`]. +/// - `x-fern-streaming: { format: json }` → [`StreamingConfig::Json`]. +/// - `x-fern-streaming: { format: text }` → [`StreamingConfig::Text`]. +/// - `{ format: sse, terminator: "[DONE]" }` → SSE with explicit terminator. +/// +/// The optional `terminator` is the literal line that ends the stream +/// — for SSE, the event payload after the `data:` prefix; for JSON, +/// the full line. When unset, the executor reads until the server +/// closes the connection (matches the TS / C# typed-SDK runtimes, +/// which also skip the terminator check when the spec didn't declare +/// one). Text streams have no terminator concept. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamingConfig { + /// Server-Sent Events stream (`format: sse`). Body is parsed line + /// by line; lines beginning with `data: ` have the prefix stripped + /// and the remainder is emitted as one event. Other SSE field + /// lines (`event:`, `id:`, `retry:`, comment lines starting with + /// `:`) are ignored at runtime. + Sse { + /// Optional sentinel line that terminates the stream + /// (compared against the post-`data: ` event payload using + /// exact equality, matching the C# generator). When `None`, + /// the stream reads to EOF; mirrors the TS/C# typed-SDK + /// behavior of only checking the terminator when the spec + /// declared one. + terminator: Option, + }, + /// Newline-delimited JSON stream (`format: json`, aka NDJSON / + /// JSONL). Each non-empty line is a complete JSON value; the + /// executor parses one value per line and emits it as it arrives. + Json { + /// Optional sentinel line that terminates the stream (compared + /// against the raw line, before JSON parsing). When `None`, + /// the stream ends when the server closes the connection. + terminator: Option, + }, + /// Plain-text line stream (`format: text`). Each non-empty line is + /// emitted verbatim as a raw string event — no JSON parsing, no + /// SSE framing strip, no terminator check. Mirrors the C# SDK + /// generator (`HttpEndpointGenerator.ts:815-825`), which reads + /// the response line-by-line and `yield return line` for any + /// non-empty line. + /// + /// `x-fern-sdk-return-value` is a no-op for text streams — the + /// event payload is already a JSON string after escaping. + Text, +} + +/// Metadata describing a binary request body. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BinaryRequestBody { + /// Content type to send with the request (e.g. `application/octet-stream`). + pub content_type: String, + /// CLI flag name (kebab-cased). Resolved from `x-fern-parameter-name` on + /// the requestBody when present; falls back to `file` for `format: binary` + /// schemas, otherwise `body`. + pub flag_name: String, +} + +/// A single field in a `multipart/form-data` request body. Each field +/// becomes a CLI flag whose value is sent as one part in the multipart +/// body. File-typed fields accept a filesystem path (or `@path` / +/// `-` for stdin) and are streamed as binary parts with the appropriate +/// content type. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MultipartField { + /// Wire name sent as the `name` in `Content-Disposition: form-data; name="..."`. + pub wire_name: String, + /// `true` when the field's schema is `type: string, format: binary` + /// (or `type: file`). File fields accept a path and stream the + /// contents; text fields send the flag value as a UTF-8 text part. + pub is_file: bool, + /// Human-readable description from the spec (surfaces in `--help`). + pub description: Option, + /// Whether the spec marks this field as required. + pub required: bool, + /// Content type hint for file parts (e.g. `application/octet-stream`). + /// Only meaningful when `is_file` is true; text parts always use + /// `text/plain; charset=utf-8`. + pub content_type: Option, +} + +/// Media upload metadata. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUpload { + pub protocols: Option, + pub accept: Option>, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUploadProtocols { + pub simple: Option, +} + +#[derive(Debug, Clone, Deserialize, Default)] +pub struct MediaUploadProtocol { + pub path: String, + pub multipart: Option, +} + +/// A reference to a schema (e.g., `{ "$ref": "File" }`). +#[derive(Debug, Clone, Deserialize, Default)] +pub struct SchemaRef { + #[serde(rename = "$ref")] + pub schema_ref: Option, + #[serde(rename = "parameterName")] + pub parameter_name: Option, +} + +/// A parameter definition for a method. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct MethodParameter { + #[serde(rename = "type")] + pub param_type: Option, + pub description: Option, + pub location: Option, + #[serde(default)] + pub required: bool, + pub format: Option, + /// Client-side default sourced only from the Fern `x-fern-default` + /// extension. When set, the generated CLI plumbs this into clap's + /// `.default_value(...)` (so the value shows up in `--help` and the + /// flag becomes optional) AND substitutes the original JSON value + /// into the outgoing request when the caller omits the flag. Stored + /// as a typed `serde_json::Value` so numbers/booleans keep their + /// wire type. + /// + /// Precedence within this field, **first match wins**: + /// 1. `x-fern-default` placed at the ref-site (next to `$ref`) + /// 2. `x-fern-default` on the resolved component parameter + /// + /// The OpenAPI standard `default:` keyword does **not** populate + /// this field — it lives separately on + /// [`documentation_default_value`]. See ticket FER-9864. + pub default_value: Option, + /// Documentation hint sourced from the OpenAPI standard `default:` + /// keyword on the parameter's `schema`. The OpenAPI spec defines + /// `default:` as describing **server** behavior when the parameter + /// is omitted — it is not a directive to clients to send the value. + /// + /// We surface this in `--help` (so users know what the API will do + /// if they leave the flag off) but we do **not** wire it into + /// clap's `.default_value(...)` and we do **not** send it on the + /// wire. Only `x-fern-default` (stored on [`default_value`]) + /// produces a client-side default. + /// + /// Ignored when `default_value` is set — the extension supersedes + /// the documentation hint for display purposes too. + pub documentation_default_value: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + pub enum_descriptions: Option>, + #[serde(default)] + pub repeated: bool, + /// True for `oneOf/anyOf [string, array]` unions where a single + /// value should be sent as a scalar string, not wrapped in a length-1 + /// array. Pure `type: array` params leave this `false`. + #[serde(default)] + pub scalar_or_array: bool, + /// Inclusive numeric lower bound (matches [`JsonSchemaProperty::minimum`]). + /// Typing the param side as `Option` keeps `--schema` output + /// emit min/max as JSON numbers regardless of whether the property + /// came from `parameters` or a request body schema. + pub minimum: Option, + /// Inclusive numeric upper bound. See [`Self::minimum`]. + pub maximum: Option, + #[serde(default)] + pub deprecated: bool, + /// OpenAPI serialization style (form, deepObject, etc.) + #[serde(default)] + pub style: Option, + /// Whether arrays/objects should be exploded into separate params. + #[serde(default)] + pub explode: Option, + /// Lowered `x-fern-availability` for the parameter. `None` is the + /// implicit default (no badge). + #[serde(default)] + pub availability: Option, + /// True when this body parameter's schema admits JSON `null` as a valid + /// value (OpenAPI 3.0 `nullable: true` or 3.1 `type: [..., "null"]`). + /// Gated on scalar `param_type` (`string` / `integer` / `number` / + /// `boolean`) — composite types stay false because the null sentinel + /// surface is scalar-only (see ADR-0003). When true, the CLI accepts + /// the literal `null` as a flag value and converts it to `Value::Null` + /// at request-build time. + #[serde(default)] + pub nullable: bool, + /// Optional environment variable that supplies a default value when + /// the corresponding CLI flag is not passed. Populated for synthetic + /// parameters injected by Fern extensions (e.g. idempotency headers); + /// not currently set for spec-declared parameters. + #[serde(default)] + pub env_var: Option, + /// Override the kebab-cased long-flag derived from the parameter's + /// HashMap key. When `Some(_)`, `commands.rs` uses this value + /// verbatim as the `--` instead of running the key through + /// `to_kebab_flag`. The clap arg ID — and the on-the-wire wire-key + /// (e.g. HTTP header name) — still derives from the HashMap key, so + /// the executor's lookup pathway is unchanged. + /// + /// Populated by `inject_idempotency_header_params` so an entry like + /// `{ header: X-Trace-Id, name: trace_id }` surfaces as `--trace-id` + /// (matching the SDK parameter naming the upstream Fern OpenAPI + /// importer produces) while still sending the `X-Trace-Id` header. + #[serde(default)] + pub flag_name_override: Option, + /// Lowered `x-fern-parameter-name` for the parameter. When `Some`, + /// the command builder renames the CLI flag (kebab-cased), while the + /// executor keeps using the original wire name (the map key) for the + /// outgoing HTTP request. Mirrors fern's OpenAPI importer, which uses + /// the alias on the SDK surface but the wire name in the request. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/parameter-name + #[serde(default)] + pub display_name: Option, + /// Lowered `x-fern-enum` per-value overrides. Keyed by the wire + /// value. Entries are only present when the spec opted into the + /// extension; absent → fall back to the raw wire value with no + /// description. + #[serde(default, skip)] + pub fern_enum: Option>, + /// Name of the spec-level `x-fern-sdk-variables` entry that supplies + /// this parameter's value. Set when the parameter carries an + /// `x-fern-sdk-variable: ` extension. Variable-bound path + /// parameters are excluded from the per-operation flag surface; their + /// value is read from the global root flag (or its env-var fallback) + /// and substituted into the path template at request time. + #[serde(default, skip)] + pub variable_reference: Option, +} + +impl MethodParameter { + /// Map a user-supplied value (which may be either the wire value or + /// the `x-fern-enum` display alias) back to the **wire** value the + /// HTTP layer must send. When no override matches, returns the input + /// unchanged so non-enum params and absent extensions are pure + /// identity. + pub fn resolve_enum_display_to_wire<'a>( + &self, + input: &'a str, + ) -> std::borrow::Cow<'a, str> { + let Some(map) = self.fern_enum.as_ref() else { + return std::borrow::Cow::Borrowed(input); + }; + for (wire, entry) in map { + if entry + .display_name + .as_deref() + .is_some_and(|name| name == input) + { + return std::borrow::Cow::Owned(wire.clone()); + } + } + std::borrow::Cow::Borrowed(input) + } +} + +/// Per-value override for `x-fern-enum`. Mirrors the Fern OpenAPI IR +/// importer's `FernEnumConfig` entry — `description` and `name` are the +/// only fields cli-sdk consumes; `casing` is reserved for SDK codegen. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FernEnumValue { + /// User-facing rendered name. When set, surfaces as the canonical + /// option in `--help` while the wire value remains accepted as an + /// alias. + pub display_name: Option, + /// Per-value description rendered in long `--help` output. + pub description: Option, +} + +/// JSON Schema definition for request/response bodies. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JsonSchema { + pub id: Option, + #[serde(rename = "type")] + pub schema_type: Option, + /// Surfaces both OpenAPI 3.0 `nullable: true` and OpenAPI 3.1 + /// `type: [..., "null"]` uniformly. Lowered by the parser, not the + /// derived deserializer. + #[serde(default)] + pub nullable: bool, + pub description: Option, + #[serde(default)] + pub properties: HashMap, + #[serde(rename = "$ref")] + pub schema_ref: Option, + pub items: Option>, + #[serde(default)] + pub required: Vec, + /// JSON Schema composition branches at the component-schema root. Mirrors + /// the same fields on [`JsonSchemaProperty`] so a top-level union like + /// `Auth0Role: { oneOf: [...] }` is captured, not just composition nested + /// inside a property. Not yet consumed by command generation. + #[serde(default)] + pub one_of: Vec, + #[serde(default)] + pub any_of: Vec, + #[serde(default)] + pub all_of: Vec, + pub additional_properties: Option>, +} + +/// A property within a JSON Schema. +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JsonSchemaProperty { + #[serde(rename = "type")] + pub prop_type: Option, + /// See [`JsonSchema::nullable`]. + #[serde(default)] + pub nullable: bool, + pub description: Option, + #[serde(rename = "$ref")] + pub schema_ref: Option, + pub format: Option, + pub items: Option>, + #[serde(default)] + pub properties: HashMap, + /// Names of nested object properties that the source schema marks as + /// required. Lowered from the OpenAPI `required: [...]` keyword on + /// object-typed schemas. Empty when the source had no `required` list + /// or when this property is not an object (e.g. scalar / array). + /// Surfaced in `--schema` so agents constructing nested JSON bodies + /// can tell which sub-fields the spec mandates. + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub read_only: bool, + /// OpenAPI's standard `default:` keyword. Stored as a `serde_json::Value` + /// (lowered from the raw YAML) so the wire type — number, boolean, + /// object, etc. — survives into the agent-facing `--schema` output + /// and is symmetric with [`MethodParameter::default_value`]. + pub default: Option, + #[serde(rename = "enum")] + pub enum_values: Option>, + /// Inclusive numeric lower bound. Lowered by the parser so the OpenAPI + /// 3.0 / 3.1 `exclusiveMinimum` divergence is resolved before reaching + /// the IR. + pub minimum: Option, + /// Inclusive numeric upper bound. See `minimum` above. + pub maximum: Option, + /// Strict numeric lower bound. Lowered uniformly from both OpenAPI 3.0 + /// (`exclusiveMinimum: true` with paired `minimum`) and 3.1 + /// (`exclusiveMinimum: `). + pub exclusive_minimum: Option, + /// Strict numeric upper bound. See `exclusive_minimum` above. + pub exclusive_maximum: Option, + /// Single example value (OpenAPI 3.0 `example` or 3.1 fallback). + pub example: Option, + /// `examples` block, captured as raw YAML. Real-world specs use this + /// field in three different shapes (3.1 array, lax-3.0 map keyed by + /// example name, single value); the parser preserves all three. + pub examples: Option, + /// JSON Schema composition branches. Lowered by the parser from + /// `oneOf`. Empty when the source had no `oneOf` block. + #[serde(default)] + pub one_of: Vec, + /// JSON Schema composition: `anyOf`. + #[serde(default)] + pub any_of: Vec, + /// JSON Schema composition: `allOf`. + #[serde(default)] + pub all_of: Vec, + pub additional_properties: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_deserialize_rest_description() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/", + "servicePath": "", + "resources": { + "users": { + "methods": { + "list": { + "httpMethod": "GET", + "path": "/users" + } + } + } + } + }"#; + + let doc: RestDescription = serde_json::from_str(json).unwrap(); + assert_eq!(doc.name, "test"); + assert_eq!(doc.version, "v1"); + + let users = doc.resources.get("users").expect("users resource missing"); + let list = users.methods.get("list").expect("list method missing"); + assert_eq!(list.http_method, "GET"); + } + + #[test] + fn test_deserialize_defaults() { + let json = r#"{ + "name": "test", + "version": "v1", + "rootUrl": "https://api.example.com/" + }"#; + + let doc: RestDescription = serde_json::from_str(json).unwrap(); + assert_eq!(doc.service_path, ""); + assert!(doc.resources.is_empty()); + assert!(doc.schemas.is_empty()); + } +} diff --git a/src/openapi/executor.rs b/src/openapi/executor.rs new file mode 100644 index 0000000..e94a968 --- /dev/null +++ b/src/openapi/executor.rs @@ -0,0 +1,11872 @@ +//! API Request Execution +//! +//! Handles building and dispatching HTTP requests to APIs. +//! Responsibilities include multipart file uploads, response pagination, +//! and error mapping. + +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +use anyhow::Context; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use futures_util::stream::TryStreamExt; +use futures_util::StreamExt; +use serde_json::{json, Map, Value}; +use tokio::io::AsyncWriteExt; + +use crate::auth::{handle_error_response, DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::openapi::discovery::{ + BodyEncoding, MethodParameter, PaginationConfig as EndpointPagination, RestDescription, + RestMethod, RetriesConfig, StreamingConfig, +}; + +/// Encoding mode for an `@`-prefixed file reference. Selected by the URI-style +/// prefix on the flag value (FER-10532). `Auto` is the implicit default — the +/// `@` form FER-10436 shipped — and is what every other call site +/// reaches when no explicit scheme is supplied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AtMode { + /// `@` — read file; embed UTF-8 when valid, base64 otherwise. + Auto, + /// `@file://` — read file; require valid UTF-8 or error. + Text, + /// `@data://` — read file; always base64-encode. + Data, +} + +/// Parsed view of a raw flag value that may carry an `@`/`\@` prefix. +/// Returned by [`parse_at_ref`] so every `@`-aware call site shares one +/// parser instead of re-implementing the prefix grammar. +pub(crate) enum AtRef<'a> { + /// Plain value with no `@` prefix; caller treats as a literal path/string. + Plain(&'a str), + /// `\@` escape; the value is the literal string `@`. + /// At the binary-body site this literal is still treated as a file path + /// (FER-10436 contract); at multipart and nested-JSON sites it is sent + /// as-is with no file read. + Escaped(String), + /// `@`, `@file://`, or `@data://` — a file reference + /// whose encoding is dictated by `mode`. `path` is the *inner* path with + /// the optional scheme prefix already stripped, ready to hand to the + /// filesystem. + File { path: Cow<'a, str>, mode: AtMode }, +} + +/// Parse the optional `@`/`\@` prefix on a raw flag value. Recognises the +/// `@file://` (text-only) and `@data://` (always-base64) scheme prefixes +/// added in FER-10532; everything else preserves the FER-10436 baseline. +pub(crate) fn parse_at_ref(raw: &str) -> AtRef<'_> { + if let Some(rest) = raw.strip_prefix("\\@") { + return AtRef::Escaped(format!("@{rest}")); + } + if let Some(rest) = raw.strip_prefix('@') { + if let Some(path) = rest.strip_prefix("file://") { + return AtRef::File { + path: Cow::Borrowed(path), + mode: AtMode::Text, + }; + } + if let Some(path) = rest.strip_prefix("data://") { + return AtRef::File { + path: Cow::Borrowed(path), + mode: AtMode::Data, + }; + } + return AtRef::File { + path: Cow::Borrowed(rest), + mode: AtMode::Auto, + }; + } + AtRef::Plain(raw) +} + +/// Strip a leading `@` (or `@file://` / `@data://`) curl-style file prefix, +/// or rewrite `\@` to a literal `@`. Returns the path string the caller +/// should hand to the filesystem (or validate). +/// +/// This is a thin convenience over [`parse_at_ref`] for the common case +/// where the caller only needs the inner path (e.g. for control-character +/// validation). When the encoding mode matters, use [`parse_at_ref`] directly. +/// +/// Semantics: +/// - `"@/tmp/foo"` → `"/tmp/foo"` +/// - `"@file:///tmp/foo"` → `"/tmp/foo"` +/// - `"@data:///tmp/foo"` → `"/tmp/foo"` +/// - `"@-"` → `"-"` (stdin sentinel; only meaningful in `Auto` mode) +/// - `"/tmp/foo"` → `"/tmp/foo"` +/// - `"\\@literal"` → `"@literal"` (escape — literal, not a file ref) +pub(crate) fn strip_or_escape_at(raw: &str) -> Cow<'_, str> { + match parse_at_ref(raw) { + AtRef::Plain(s) => Cow::Borrowed(s), + AtRef::Escaped(literal) => Cow::Owned(literal), + AtRef::File { path, .. } => path, + } +} + +/// Returns `true` if `raw` is a `\@`-escaped literal (i.e. the caller should +/// treat the value as a literal string, NOT read from disk). +pub(crate) fn is_escaped_literal(raw: &str) -> bool { + raw.starts_with("\\@") +} + +/// Encode raw file bytes for embedding in a JSON string position or other +/// text-like context, applying [`AtMode`] semantics. `field` and `path` are +/// passed only to format the UTF-8 error from `AtMode::Text`. +pub(crate) fn encode_file_bytes_for_text( + bytes: Vec, + mode: AtMode, + field: &str, + path: &str, +) -> Result { + match mode { + AtMode::Auto => Ok(match String::from_utf8(bytes) { + Ok(text) => text, + Err(e) => BASE64.encode(e.into_bytes()), + }), + AtMode::Text => String::from_utf8(bytes).map_err(|_| { + CliError::Validation(format!( + "@file://{path} for {field}: file is not valid UTF-8 \ + (use @data://{path} to base64-encode binary content, \ + or drop the explicit scheme for auto-detection)" + )) + }), + AtMode::Data => Ok(BASE64.encode(bytes)), + } +} + +/// Walk a parsed JSON value and resolve `@filename` references found in +/// string positions. Mirrors Stainless's CLI generator behavior for the +/// object-shorthand body-flag path (e.g. `--profile '{"pic":"@abe.jpg"}'`). +/// +/// Rewrites in place: +/// - `"\\@literal"` → `"@literal"` (escape — no file read) +/// - `"@"` → file contents (UTF-8 if valid, otherwise base64) +/// - `"@file://"` → file contents as UTF-8 (errors if not UTF-8) +/// - `"@data://"` → base64-encoded file contents (always) +/// - anything else → unchanged +/// +/// Errors include the file path and a JSON-Pointer-style path to the offending +/// field for debuggability (e.g. `"/foo/bar/0/baz"`). FER-10436, FER-10532. +pub(crate) fn resolve_file_refs(value: &mut Value) -> Result<(), CliError> { + resolve_file_refs_at_path(value, &mut String::new()) +} + +/// Recursive worker for [`resolve_file_refs`]. `pointer` is the current +/// JSON-Pointer-style path (per RFC 6901, e.g. `""`, `"/foo"`, `"/foo/bar/0"`) +/// to the value being inspected — extended in place as we descend and +/// truncated on the way back up to avoid per-level allocations. +fn resolve_file_refs_at_path(value: &mut Value, pointer: &mut String) -> Result<(), CliError> { + match value { + Value::String(s) => { + match parse_at_ref(s) { + // `\@literal` → `@literal`; no disk access. + AtRef::Escaped(literal) => *s = literal, + // No `@` prefix — leave the value alone. + AtRef::Plain(_) => {} + AtRef::File { path, mode } => { + let field = if pointer.is_empty() { "/" } else { pointer.as_str() }; + // Match the path-safety contract every other file-read site + // honors (binary body in binding.rs, multipart in app.rs): + // reject control chars / dangerous Unicode before disk I/O so + // an adversarial nested value like `@evil\x00path` can't + // bypass the safety net the other entry points enforce. + crate::output::reject_dangerous_chars( + path.as_ref(), + &format!("JSON field '{field}'"), + )?; + let bytes = std::fs::read(path.as_ref()).map_err(|io_err| { + CliError::Validation(format!( + "Failed to read file '{}' for JSON field '{field}': {io_err}", + path.as_ref() + )) + })?; + // Auto preserves the FER-10436 behavior (UTF-8 or base64); + // Text and Data are the FER-10532 explicit modes. + *s = encode_file_bytes_for_text( + bytes, + mode, + &format!("JSON field '{field}'"), + path.as_ref(), + )?; + } + } + Ok(()) + } + Value::Array(items) => { + for (i, item) in items.iter_mut().enumerate() { + let original_len = pointer.len(); + pointer.push('/'); + pointer.push_str(&i.to_string()); + resolve_file_refs_at_path(item, pointer)?; + pointer.truncate(original_len); + } + Ok(()) + } + Value::Object(map) => { + for (k, v) in map.iter_mut() { + let original_len = pointer.len(); + pointer.push('/'); + // Per RFC 6901 §4: `~` → `~0`, `/` → `~1`. + for ch in k.chars() { + match ch { + '~' => pointer.push_str("~0"), + '/' => pointer.push_str("~1"), + other => pointer.push(other), + } + } + resolve_file_refs_at_path(v, pointer)?; + pointer.truncate(original_len); + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Resolved source for a binary request body (octet-stream uploads etc.). +/// +/// Driven by the value passed on the CLI's binary-body flag (`--file`, `--body`, +/// or whatever name the spec dictates). Accepts six forms: +/// +/// - `` — plain filesystem path. Sent with `Content-Length`. +/// - `@` — same path, curl-style prefix. Sent with `Content-Length`. +/// - `@file://` — read; require valid UTF-8 or error (FER-10532). +/// - `@data://` — read; always base64-encode the bytes (FER-10532). +/// - `\@` — escape: send a file whose path is literally `@`. +/// - `-` or `@-` — read from stdin. Sent with `Transfer-Encoding: chunked` +/// (no length). `@file://-` and `@data://-` are treated as a literal file +/// path named `-`, not stdin. +pub enum BinaryBodySource<'a> { + /// Stream from a file on disk. `mode` selects raw-byte streaming + /// (`Auto`) vs. read-into-memory transforms (`Text`, `Data`). + /// Owned `Cow` covers the `\@literal` escape; borrowed covers the + /// common `@path` and `path` cases. + File { path: Cow<'a, str>, mode: AtMode }, + /// Read from stdin. Body is streamed with chunked transfer encoding. + Stdin, +} + +impl<'a> BinaryBodySource<'a> { + /// Parse a raw flag value into one of the accepted forms. Stripping the + /// optional `@` prefix (or applying the `\@` escape) happens here so the + /// rest of the pipeline only sees a clean path or `Stdin`. + pub fn parse(raw: &'a str) -> Self { + match parse_at_ref(raw) { + // Bare `-` and `@-` map to stdin. An explicit scheme (`@file://-` / + // `@data://-`) is *not* stdin — the user opted into a literal + // file-path interpretation, so `-` is just a filename. + AtRef::Plain("-") => Self::Stdin, + AtRef::File { path, mode: AtMode::Auto } if path.as_ref() == "-" => Self::Stdin, + AtRef::File { path, mode } => Self::File { path, mode }, + // `\@` resolves to a literal-path file read at this site + // (FER-10436). The literal already carries no scheme prefix, so + // it inherits `Auto` byte-streaming semantics. + AtRef::Escaped(literal) => Self::File { + path: Cow::Owned(literal), + mode: AtMode::Auto, + }, + AtRef::Plain(s) => Self::File { + path: Cow::Borrowed(s), + mode: AtMode::Auto, + }, + } + } +} + +/// Source for media upload content. +/// +/// Two mutually exclusive strategies: upload from a file on disk (for Drive, +/// Chat, etc.) or from in-memory bytes (for Gmail's constructed RFC 5322 +/// messages). Using an enum makes illegal states (both set, or mismatched +/// content types) unrepresentable. +pub enum UploadSource<'a> { + /// Stream from a file on disk. Content type is inferred from the file + /// extension, overridden by metadata mimeType, or explicitly set. + File { + path: &'a str, + content_type: Option<&'a str>, + }, + /// Upload from in-memory bytes with an explicit content type. + Bytes { + data: &'a [u8], + content_type: &'a str, + }, +} + +/// A single part in a multipart/form-data request, collected from CLI +/// flags for operations that declare `multipart/form-data` bodies. +pub enum MultipartPart { + /// UTF-8 text value sent as a plain form field. `content_type` carries + /// an explicit per-part `Content-Type` from the OpenAPI `encoding` + /// object; `None` means reqwest's default (`text/plain`). + Text { + name: String, + value: String, + content_type: Option, + }, + /// File read from disk (or stdin). The `path` is already validated. + /// `content_type` is the per-part `Content-Type` resolved from the + /// OpenAPI `encoding` object (falling back to the schema-inferred + /// `application/octet-stream` for file fields). + File { + name: String, + path: String, + content_type: Option, + }, +} + +/// Configuration for auto-pagination. +#[derive(Debug, Clone)] +pub struct PaginationConfig { + /// Whether to auto-paginate through all pages. + pub page_all: bool, + /// Maximum number of pages to fetch (default: 10). + pub page_limit: u32, + /// Delay between page fetches in milliseconds (default: 100). + pub page_delay_ms: u64, + /// Query parameter name for the page token (default: "pageToken"). + pub token_query_param: String, + /// Dotted path in JSON response to find the next page token (default: "nextPageToken"). + /// Supports nested paths like "pagination.next_page_token". + pub token_response_path: String, + /// Disable the pager even on interactive terminals (`--no-pager`). + pub no_pager: bool, + /// CLI binary name, used for the `_PAGER` env var lookup. + pub cli_name: String, +} + +impl Default for PaginationConfig { + fn default() -> Self { + Self { + page_all: false, + page_limit: 10, + page_delay_ms: 100, + token_query_param: "pageToken".to_string(), + token_response_path: "nextPageToken".to_string(), + no_pager: false, + cli_name: String::new(), + } + } +} + +/// Outcome of a single retry-loop iteration. +/// +/// Captures everything the retry policy needs to make its next decision: +/// the HTTP status (or `None` for transport-layer failures), the +/// `Retry-After` header value if any, and the wall-clock timestamp the +/// header should be interpreted against. Keeping the timestamp on the +/// outcome lets unit tests pin `SystemTime::now` to a known instant +/// without monkey-patching the global clock. +#[derive(Debug)] +pub(crate) struct RetryOutcome<'a> { + pub status: Option, + pub retry_after: Option<&'a str>, +} + +/// Returns `true` when the HTTP status code is considered retryable. +/// +/// Retryable statuses (FER-10521): +/// - 408 Request Timeout — server gave up before reading; safe. +/// - 429 Too Many Requests — backoff signal; safe. +/// - 500–599 (all server errors) — transient infrastructure failures. +/// +/// Prior to FER-10521 this excluded 500 (often a non-transient bug). +/// The broader 5xx set aligns with the cross-SDK default and with +/// the expectation that CLIs retry aggressively on server-side errors. +pub(crate) fn is_retryable_status(status: u16) -> bool { + status == 408 || status == 429 || (500..=599).contains(&status) +} + +/// Whether the per-method retry policy allows retrying *non-idempotent* +/// HTTP responses (e.g. a 503 on a POST). GET / HEAD / OPTIONS / DELETE +/// / PUT are idempotent by the HTTP spec; `x-fern-idempotent` on the +/// operation marks an otherwise-unsafe method (POST / PATCH) as +/// safe-to-retry, which mirrors fern's per-method retry policy. +pub(crate) fn method_allows_retry(http_method: &str, marked_idempotent: bool) -> bool { + if marked_idempotent { + return true; + } + matches!( + http_method.to_ascii_uppercase().as_str(), + "GET" | "HEAD" | "OPTIONS" | "DELETE" | "PUT" + ) +} + +/// Whether the given `binary_body_path` raw string designates stdin +/// (`-` or `@-`). Stdin-sourced bodies cannot be replayed on retry — +/// the pipe is consumed by the first send — so callers must disable +/// retries when this returns `true`. Mirrors `BinaryBodySource::parse` +/// without the lifetime gymnastics needed at the call site. +pub(crate) fn binary_body_is_stdin(binary_body_path: Option<&str>) -> bool { + match binary_body_path { + Some(raw) => matches!(BinaryBodySource::parse(raw), BinaryBodySource::Stdin), + None => false, + } +} + +/// Whether any `MultipartPart::File` in the list uses stdin (`-` or `@-`). +/// Stdin-sourced file parts cannot be replayed on retry because the pipe is +/// consumed by the first `read_to_end`. Mirrors `binary_body_is_stdin`. +pub(crate) fn multipart_has_stdin(parts: &Option>) -> bool { + match parts { + Some(parts) => parts.iter().any(|p| match p { + MultipartPart::File { path, .. } => match parse_at_ref(path) { + // `\@literal` is an escape, not a stdin sentinel — route it + // to the file branch instead. FER-10436. + AtRef::Escaped(_) => false, + // Only `Auto`-mode `@-` (or bare `-`) is stdin. An explicit + // scheme like `@file://-` means a literal file path named `-`. + AtRef::File { path, mode: AtMode::Auto } => path.as_ref() == "-", + AtRef::File { .. } => false, + AtRef::Plain(s) => s == "-", + }, + MultipartPart::Text { .. } => false, + }), + None => false, + } +} + +/// Parse a `Retry-After` header value into a `Duration`. +/// +/// HTTP/1.1 allows two forms (RFC 7231 §7.1.3): a non-negative integer +/// number of seconds, or an HTTP-date. We accept either. Past dates +/// (the server's clock is ahead, or `Retry-After: 0`) collapse to +/// zero so callers don't underflow. +pub(crate) fn parse_retry_after(value: &str, now: std::time::SystemTime) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + // Numeric seconds first — cheaper and far more common in practice. + if let Ok(secs) = trimmed.parse::() { + return Some(std::time::Duration::from_secs(secs)); + } + // HTTP-date (IMF-fixdate / RFC 850 / asctime). `httpdate::parse_http_date` + // accepts all three formats per RFC 7231. + if let Ok(target) = httpdate::parse_http_date(trimmed) { + return Some(target.duration_since(now).unwrap_or(std::time::Duration::ZERO)); + } + None +} + +/// Compute the delay for the *next* retry attempt (i.e. the wait +/// between attempt `attempt` and attempt `attempt + 1`). +/// +/// Math: `base * factor^attempt`, with deterministic jitter in +/// `[1 - jitter/2, 1 + jitter/2]` applied to the result. The jitter +/// factor is sampled from a fast LCG so test runs are deterministic +/// when seeded — see [`compute_backoff_delay_with_rand`] below. +pub(crate) fn compute_backoff_delay( + attempt: u32, + config: &RetriesConfig, +) -> std::time::Duration { + // Use system entropy for the random sample. Unit tests use + // `compute_backoff_delay_with_rand` to pin the sample. + let jitter_sample = if config.jitter > 0.0 { + // Sub-second component of wall-clock time as cheap entropy. + // We don't care about cryptographic quality here — just enough + // variance to de-correlate retries from competing clients + // (i.e. avoid the thundering-herd problem during an outage). + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .subsec_nanos() as u64; + ((nanos.wrapping_mul(2654435761)) & 0xFFFF) as f64 / 65535.0 + } else { + 0.5 + }; + compute_backoff_delay_with_rand(attempt, config, jitter_sample) +} + +/// Test-friendly variant of [`compute_backoff_delay`]. `rand_unit` is +/// any value in `[0.0, 1.0]`; pass `0.5` for the "exact" backoff +/// (no jitter offset). +pub(crate) fn compute_backoff_delay_with_rand( + attempt: u32, + config: &RetriesConfig, + rand_unit: f64, +) -> std::time::Duration { + if !config.enabled { + return std::time::Duration::ZERO; + } + let exponent = attempt as i32; + let raw_ms = (config.base_delay_ms as f64) * config.factor.powi(exponent); + + // Jitter spreads the delay symmetrically around `raw_ms` to + // de-correlate clients all retrying off the same server outage. + let jitter_span = raw_ms * config.jitter; + let offset = (rand_unit.clamp(0.0, 1.0) - 0.5) * jitter_span; + let ms = (raw_ms + offset).max(0.0); + + // Cap at u64 to avoid panics on absurd configs (e.g. factor=1e9). + let capped = if ms > u64::MAX as f64 { + u64::MAX + } else { + ms as u64 + }; + std::time::Duration::from_millis(capped) +} + +/// Decide whether to retry after an HTTP outcome. +/// +/// Returns `Some(delay)` to schedule a retry, or `None` to surface the +/// outcome to the caller. Encapsulates the precedence rules in one +/// place so the wire executor stays a thin loop body. +pub(crate) fn decide_retry( + attempt: u32, + outcome: &RetryOutcome<'_>, + config: &RetriesConfig, + http_method: &str, + marked_idempotent: bool, + no_retry: bool, +) -> Option { + // Hard opt-outs first. + if no_retry || !config.enabled || config.max_attempts == 0 { + return None; + } + // attempt is 0-indexed (the request just completed was attempt + // `attempt`); we retry while we still have room before + // `max_attempts` total sends. + if attempt + 1 >= config.max_attempts { + return None; + } + + match outcome.status { + // Network / transport failure (no response at all). + None => { + // Network errors are always treated as transient. GET-like + // methods retry per default; POST/PATCH only when the + // operation is explicitly marked idempotent. + if !method_allows_retry(http_method, marked_idempotent) { + return None; + } + Some(compute_backoff_delay(attempt, config)) + } + Some(status) => { + if !is_retryable_status(status) { + return None; + } + // 408/429 are safe to retry on any method (the request + // didn't reach business logic). 5xx on non-idempotent + // methods *could* have been processed — respect per-method + // policy unless the op is marked idempotent. + let always_safe = matches!(status, 408 | 429); + if !always_safe && !method_allows_retry(http_method, marked_idempotent) { + return None; + } + // Honor `Retry-After` when present, fall back to backoff. + if let Some(raw) = outcome.retry_after { + if let Some(d) = parse_retry_after(raw, std::time::SystemTime::now()) { + return Some(d); + } + } + Some(compute_backoff_delay(attempt, config)) + } + } +} + +/// Returns true if any dotted ancestor of `leaf_path` is present in `supplied` +/// and is declared as an object-shorthand parameter (`param_type == "object"`). +/// Used so a required leaf like `name.first` is not reported missing when the +/// user satisfied it via `--name '{"first": "..."}'`. +fn ancestor_object_shorthand_supplied( + leaf_path: &str, + supplied: &Map, + parameters: &std::collections::HashMap, +) -> bool { + let segments: Vec<&str> = leaf_path.split('.').collect(); + // Walk ancestors longest-first: a.b.c.d → a.b.c, a.b, a + for prefix_len in (1..segments.len()).rev() { + let ancestor = segments[..prefix_len].join("."); + if supplied.contains_key(&ancestor) + && parameters + .get(&ancestor) + .and_then(|p| p.param_type.as_deref()) + == Some("object") + { + return true; + } + } + false +} + +/// Parsed and validated inputs ready for request execution. +#[derive(Debug)] +struct ExecutionInput { + body: Option, + full_url: String, + query_params: Vec<(String, String)>, + header_params: Vec<(String, String)>, + is_upload: bool, +} + +/// Parse parameters and body JSON, validate against schema, check required params, and build the URL. +fn parse_and_validate_inputs( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + is_media_upload: bool, + base_url_override: Option<&str>, + extra_headers: &[(String, String)], + extra_global_params: &[crate::openapi::app::ResolvedGlobalParam], +) -> Result { + let params: Map = if let Some(p) = params_json { + serde_json::from_str(p) + .map_err(|e| CliError::Validation(format!("Invalid --params JSON: {e}")))? + } else { + Map::new() + }; + + // Helper: build the `Provide it via …` hint. Uses the same + // `resolve_param_flag_name` that the command builder uses so the + // suggested `--` matches the actually registered flag + // (including body-param dot-notation and `-param` builtin suffix). + let missing_param_hint = |param_def: &MethodParameter, param_name: &str| -> String { + let flag = crate::openapi::commands::resolve_param_flag_name(param_def, param_name) + .unwrap_or_else(|| crate::text::to_kebab_flag(param_name)); + if param_def.location.as_deref() == Some("body") { + format!("Provide it via --{flag}, --json, or --params") + } else { + format!("Provide it via --{flag} or --params") + } + }; + + // Declared parameters whose value will be supplied by a resolved + // global parameter (targeting the same wire name). These are exempt + // from the required-param checks below: their value is injected after + // validation (see the `extra_global_params` loop), and a required + // global without a resolved value already errored in + // `build_global_parameter_overrides`. Without this exemption a + // `location: path` global (whose OpenAPI target must be declared as a + // required path variable) would always trip the check before its + // value is ever applied. + let global_param_targets: std::collections::HashSet<&str> = + extra_global_params.iter().map(|gp| gp.target.as_str()).collect(); + + for param_name in &method.parameter_order { + if let Some(param_def) = method.parameters.get(param_name) { + if param_def.required + && param_def.location.as_deref() == Some("path") + && !params.contains_key(param_name) + && !global_param_targets.contains(param_name.as_str()) + { + let hint = missing_param_hint(param_def, param_name); + return Err(CliError::Validation(format!( + "Required path parameter '{param_name}' is missing. {hint}" + ))); + } + } + } + + for (param_name, param_def) in &method.parameters { + if param_def.required + && !params.contains_key(param_name) + && !global_param_targets.contains(param_name.as_str()) + { + // When --json is provided, body-located required params are satisfied + // by the JSON payload — skip their individual-flag validation. + if param_def.location.as_deref() == Some("body") && body_json.is_some() { + continue; + } + // When the user supplied an ancestor object-shorthand flag + // (e.g. `--name '{...}'`) the required-ness of nested leaves + // (`name.first`) is satisfied inside the JSON payload, not via + // a per-leaf flag — skip them here. + if param_def.location.as_deref() == Some("body") + && param_name.contains('.') + && ancestor_object_shorthand_supplied(param_name, ¶ms, &method.parameters) + { + continue; + } + let hint = missing_param_hint(param_def, param_name); + return Err(CliError::Validation(format!( + "Required parameter '{param_name}' is missing. {hint}" + ))); + } + } + + // Split params by `location` into header / body / non-header buckets. + // Body-located params are coerced by type and turned into the JSON body + // when no --json is also supplied. (JFL-1.2 makes those modes mutually + // exclusive — see the conflict checks below.) + let mut header_params: Vec<(String, String)> = Vec::new(); + let mut body_from_flags = Map::new(); + let mut non_header_params = Map::new(); + // Track the raw (pre-`set_nested_value`) body flag keys the user provided + // so we can detect collisions between an object-shorthand flag and a + // dot-notation leaf flag for the same field. We can't introspect + // `body_from_flags` after the fact because `set_nested_value` collapses + // dotted keys into nested maps, erasing the original input shape. + let mut raw_body_flag_keys: Vec = Vec::new(); + + for (key, value) in ¶ms { + let location = method.parameters.get(key).and_then(|p| p.location.as_deref()); + match location { + Some("header") => { + let param_def = method.parameters.get(key); + let str_value = serialize_header_simple(value, param_def)?; + header_params.push((key.clone(), str_value)); + } + Some("body") => { + raw_body_flag_keys.push(key.clone()); + let coerced = coerce_body_param_value( + value, + method.parameters.get(key).and_then(|p| p.param_type.as_deref()), + )?; + set_nested_value(&mut body_from_flags, key, coerced); + } + _ => { + non_header_params.insert(key.clone(), value.clone()); + } + } + } + + // JFL-1.2: enforce mutually exclusive body input modes. The three modes + // are (1) `--json` whole-body, (2) dot-notation leaf flags, and + // (3) object-shorthand JSON for a single field (`--name '{...}'`). + // Mixing any two is a validation error so the user's intent is + // unambiguous and the precedence rules are not surprising. + if body_json.is_some() && !raw_body_flag_keys.is_empty() { + let conflicting = raw_body_flag_keys + .iter() + .map(|k| format!("--{k}")) + .collect::>() + .join(", "); + return Err(CliError::Validation(format!( + "Cannot combine --json with per-field body flags ({conflicting}). Use one or the other." + ))); + } + for object_key in &raw_body_flag_keys { + let is_object = method + .parameters + .get(object_key) + .and_then(|p| p.param_type.as_deref()) + == Some("object"); + if !is_object { + continue; + } + let prefix = format!("{object_key}."); + if let Some(leaf_key) = raw_body_flag_keys.iter().find(|k| k.starts_with(&prefix)) { + return Err(CliError::Validation(format!( + "Cannot combine --{object_key} with --{leaf_key}. Use the JSON shorthand or individual flags, not both." + ))); + } + } + + // Append spec-root `x-fern-global-headers` last so per-operation + // headers (already populated above from `params`) override globals + // with the same wire-name. Resolution of CLI flag / env / default + // happens upstream in `run_async`; the executor's job here is just + // to stamp the resolved value on the request when no per-op + // parameter already supplied it. + for (name, value) in extra_headers { + if !header_params.iter().any(|(k, _)| k == name) { + header_params.push((name.clone(), value.clone())); + } + } + + // Inject resolved `x-fern-global-parameters` by location. Header + // and body params are stamped here; query and path params are added + // to `non_header_params` so `build_url` handles them. + for gp in extra_global_params { + use crate::openapi::discovery::GlobalParameterLocation; + match gp.location { + GlobalParameterLocation::Header => { + if !header_params.iter().any(|(k, _)| k.eq_ignore_ascii_case(&gp.target)) { + header_params.push((gp.target.clone(), gp.value.clone())); + } + } + GlobalParameterLocation::Query => { + if !non_header_params.contains_key(&gp.target) { + non_header_params.insert( + gp.target.clone(), + Value::String(gp.value.clone()), + ); + } + } + GlobalParameterLocation::Body => { + // Body injection is deferred until after body assembly + // so that global body params are merged into both the + // `--json` path and the per-field-flags path. + } + GlobalParameterLocation::Path => { + if !non_header_params.contains_key(&gp.target) { + non_header_params.insert( + gp.target.clone(), + Value::String(gp.value.clone()), + ); + } + } + } + } + + // The conflict checks above guarantee that `body_json` and + // `body_from_flags` are never both populated (before global-param + // injection), so the body is sourced from exactly one channel here. + let body: Option = if let Some(b) = body_json { + let mut json_val: Value = serde_json::from_str(b) + .map_err(|e| CliError::Validation(format!("Invalid --json body: {e}")))?; + // Resolve `@file` references inside `--json` bodies, matching the + // behavior of per-field object-shorthand flags (FER-10436). + // + // Called directly (no `block_in_place`) because this sync function + // is reached from two async contexts: + // a) binding.rs dispatch → `execute_method().await` + // b) app.rs custom-command → `block_in_place` → `block_on` → + // `execute_method` + // Wrapping in `block_in_place` here would create a nested + // `block_in_place → block_on → block_in_place` chain in path (b). + // The file reads are brief, and for a CLI tool the momentary + // blocking of a single worker thread is acceptable. + resolve_file_refs(&mut json_val)?; + Some(json_val) + } else if !body_from_flags.is_empty() { + Some(Value::Object(body_from_flags)) + } else { + None + }; + + // Merge body-location global parameters into the assembled body. + // This runs after body assembly so globals are injected into both + // the `--json` and per-field-flags paths. A value the user already + // supplied at the target path — including a nested path like + // `config.currency` — is never overwritten (per-op wins, enforced by + // `set_nested_value_if_absent`, which walks the dotted path). + let body = merge_global_body_params(body, extra_global_params); + + // Validate the assembled body against the request schema regardless of + // how it was built (per-field flags, `--json`, or both). The previous + // version only validated on the `--json` path, which let per-field-flag + // bodies skip schema checks even though those values arrive as + // CLI-typed strings and are more likely to violate the schema. + if let Some(ref body_val) = body { + if let Some(ref req_ref) = method.request { + if let Some(ref schema_name) = req_ref.schema_ref { + validate_body_against_schema(body_val, schema_name, doc)?; + } + } + } + + let (full_url, query_params) = build_url(doc, method, &non_header_params, is_media_upload, base_url_override)?; + let is_upload = is_media_upload && method.supports_media_upload; + + Ok(ExecutionInput { + body, + full_url, + query_params, + header_params, + is_upload, + }) +} + +/// Build the per-operation auth metadata from the lowered security +/// requirements. Computed once per execute_method call and reused across +/// pagination iterations — the requirements don't change page to page. +fn endpoint_metadata_for( + method: &RestMethod, + base_url_override: Option<&str>, +) -> EndpointAuthMetadata { + EndpointAuthMetadata { + security_requirements: method.security_requirements.clone(), + base_url_override: base_url_override.map(str::to_string), + } +} + +/// Pagination loop state tracked across page fetches. +/// +/// Each variant matches one of the five `x-fern-pagination` forms, plus +/// the document-level heuristic (which uses [`PageState::Cursor`]): +/// +/// - [`PageState::Cursor`] — token threaded through a request query param +/// - [`PageState::Offset`] — running offset counter sent as a query param +/// - [`PageState::NextUrl`] — server-returned absolute URL (uri form) or +/// resolved relative path (path form) used verbatim for the next page +/// - [`PageState::Custom`] — single-shot; the executor never continues +/// +/// Encoded as a discriminated union rather than several `Option`s so that +/// callers can't accidentally mix semantics from different forms. +#[derive(Debug)] +enum PageState { + Cursor(Option), + Offset(u64), + /// `None` on the first page, `Some(url)` once the previous response + /// supplied a next URL/path. The string is always a fully-qualified + /// URL — relative `next_path` values are resolved against the + /// previous request's URL before being stored here. + NextUrl(Option), + Custom, +} + +impl PageState { + /// Pick the initial state from the resolved per-operation pagination + /// config. Operations without explicit `x-fern-pagination` (or with + /// cursor-style config) start with no token; offset-style starts at + /// 0; uri/path/custom forms start in their respective first-page + /// states. + fn initial(endpoint: Option<&EndpointPagination>) -> Self { + match endpoint { + Some(EndpointPagination::Offset { .. }) => PageState::Offset(0), + Some(EndpointPagination::Uri { .. } | EndpointPagination::Path { .. }) => { + PageState::NextUrl(None) + } + Some(EndpointPagination::Custom { .. }) => PageState::Custom, + // Cursor + heuristic + None all use the cursor-style state. + _ => PageState::Cursor(None), + } + } + + /// Override the outgoing URL when the pagination form does so (uri / + /// path). `None` means leave the request's URL untouched. + fn url_override(&self) -> Option<&str> { + match self { + PageState::NextUrl(Some(url)) => Some(url.as_str()), + _ => None, + } + } + + /// Convert the state into the (query-param name, value) pair to inject + /// on the next outgoing request, or `None` when the state represents + /// "first page, no extra param yet" or "URL is fully self-contained". + fn injection( + &self, + endpoint: Option<&EndpointPagination>, + heuristic_param: &str, + ) -> Option<(String, String)> { + match self { + PageState::Cursor(None) => None, + PageState::Cursor(Some(token)) => { + let name = match endpoint { + Some(EndpointPagination::Cursor { cursor, .. }) => cursor.clone(), + _ => heuristic_param.to_string(), + }; + Some((name, token.clone())) + } + PageState::Offset(0) => None, + PageState::Offset(n) => { + let name = match endpoint { + Some(EndpointPagination::Offset { offset, .. }) => offset.clone(), + _ => heuristic_param.to_string(), + }; + Some((name, n.to_string())) + } + // Uri / Path embed the cursor in the URL itself. + PageState::NextUrl(_) | PageState::Custom => None, + } + } +} + +/// Build an HTTP request with auth, query params, page token, and body/multipart attachment. +#[allow(clippy::too_many_arguments)] +async fn build_http_request( + client: &reqwest::Client, + method: &RestMethod, + input: &ExecutionInput, + auth_provider: &DynAuthProvider, + auth_metadata: &EndpointAuthMetadata, + page_state: &PageState, + pages_fetched: u32, + upload: &Option>, + binary_body_path: Option<&str>, + multipart_parts: &Option>, + pagination: &PaginationConfig, +) -> Result { + // Uri / Path pagination supplies a fully-resolved next URL in the + // page state; use it verbatim so that the server's cursor / query + // params travel as-is. + let base_target_url = page_state.url_override().unwrap_or(&input.full_url); + + // Build the query string ourselves (rather than reqwest's `.query()`, + // which form-encodes: space -> `+`, comma -> `%2C`). The OpenAPI 3.0 + // query styles need RFC 3986 percent-encoding with style delimiters left + // literal in the joined value (spaceDelimited -> `%20`, pipeDelimited -> + // `%7C`, form/no-explode arrays keep `,`). When the URL is supplied by the + // server (uri / path pagination) it already carries every query param the + // server cares about, so we honor it as-is. + let target_url = if page_state.url_override().is_some() { + base_target_url.to_string() + } else { + let mut all_query_params = input.query_params.clone(); + if let Some((name, value)) = + page_state.injection(method.pagination.as_ref(), &pagination.token_query_param) + { + all_query_params.push((name, value)); + } + // Upload operations carry `uploadType=multipart`; route it through the + // same RFC-3986 query encoder as every other param instead of reqwest's + // `.query()` (the value is an ASCII literal, so the encoded form is + // identical — this just keeps a single query encoder). + if pages_fetched == 0 && upload.is_some() { + all_query_params.push(("uploadType".to_string(), "multipart".to_string())); + } + append_query_string(base_target_url, &all_query_params) + }; + + let mut request = match method.http_method.as_str() { + "GET" => client.get(&target_url), + "POST" => client.post(&target_url), + "PUT" => client.put(&target_url), + "PATCH" => client.patch(&target_url), + "DELETE" => client.delete(&target_url), + other => { + return Err(CliError::Other(anyhow::anyhow!( + "Unsupported HTTP method: {other}" + ))) + } + }; + + // `security: []` in the spec means the operation opts out of auth. + // Short-circuit before involving the provider so leaf providers + // (Bearer/Basic/Header) and composition wrappers that don't inspect + // the endpoint (AnyAuthProvider, AllAuthProvider, user-built custom + // providers) can't leak credentials onto an explicitly anonymous + // endpoint. RoutingAuthProvider already honors this internally; the + // executor-side check makes it universal. + if !auth_metadata.is_explicit_anonymous() { + request = auth_provider.apply(request, auth_metadata)?; + } + + // Prefer JSON when the API supports content negotiation (some providers + // return XML otherwise). Only inject when the operation doesn't already + // set an Accept header. + if !input + .header_params + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("accept")) + { + request = request.header("Accept", "application/json"); + } + + // Send header parameters as HTTP headers + for (name, value) in &input.header_params { + if let Ok(header_value) = reqwest::header::HeaderValue::from_str(value) { + request = request.header(name.as_str(), header_value); + } + } + + if pages_fetched == 0 { + if let Some(upload_source) = upload { + let (body, content_type, content_length) = match upload_source { + UploadSource::Bytes { data, content_type } => { + if content_type.contains('\r') || content_type.contains('\n') { + return Err(CliError::Validation( + "Upload content type must not contain CR or LF".to_string(), + )); + } + build_multipart_bytes(&input.body, data, content_type)? + } + UploadSource::File { path, content_type } => { + let file_meta = tokio::fs::metadata(path).await.map_err(|e| { + CliError::Validation(format!( + "Failed to get metadata for upload file '{path}': {e}" + )) + })?; + let file_size = file_meta.len(); + let media_mime = resolve_upload_mime(*content_type, Some(path), &input.body); + build_multipart_stream(&input.body, path, file_size, &media_mime)? + } + }; + request = request.header("Content-Type", content_type); + request = request.header("Content-Length", content_length); + request = request.body(body); + } else if let Some(raw) = binary_body_path { + let binary = method.binary_request_body.as_ref().ok_or_else(|| { + CliError::Validation( + "binary body path was provided but the operation has no binary request body declared" + .to_string(), + ) + })?; + request = request.header("Content-Type", &binary.content_type); + match BinaryBodySource::parse(raw) { + BinaryBodySource::File { path, mode: AtMode::Auto } => { + let path_ref: &str = path.as_ref(); + let file_meta = tokio::fs::metadata(path_ref).await.map_err(|e| { + CliError::Validation(format!( + "Failed to read --{} '{path_ref}': {e}", + binary.flag_name + )) + })?; + let (body, content_length) = + build_binary_file_stream(path_ref, file_meta.len(), &binary.flag_name); + request = request.header("Content-Length", content_length); + request = request.body(body); + } + BinaryBodySource::File { path, mode } => { + // `Text` / `Data` modes (FER-10532) need the full bytes in + // memory to validate UTF-8 or to base64-encode — there is + // no streaming form of either transform. The transformed + // body becomes a sized in-memory buffer. + let path_ref: &str = path.as_ref(); + let bytes = tokio::fs::read(path_ref).await.map_err(|e| { + CliError::Validation(format!( + "Failed to read --{} '{path_ref}': {e}", + binary.flag_name + )) + })?; + let body_bytes = encode_file_bytes_for_text( + bytes, + mode, + &format!("--{}", binary.flag_name), + path_ref, + )? + .into_bytes(); + let content_length = body_bytes.len() as u64; + request = request.header("Content-Length", content_length); + request = request.body(reqwest::Body::from(body_bytes)); + } + BinaryBodySource::Stdin => { + // No Content-Length — reqwest emits Transfer-Encoding: chunked. + // Memory stays at O(64 KB) regardless of input size. + request = request.body(build_stdin_body_stream()); + } + } + } else if let Some(parts) = multipart_parts { + let form = build_multipart_form(parts).await?; + request = request.multipart(form); + } else if let Some(ref body_val) = input.body { + request = encode_request_body(request, body_val, &method.body_encoding); + } else if matches!(method.http_method.as_str(), "POST" | "PUT" | "PATCH") { + request = request.header("Content-Length", "0"); + } + } else if let Some(ref body_val) = input.body { + request = encode_request_body(request, body_val, &method.body_encoding); + } + + Ok(request) +} + +/// Walk a dotted path like "pagination.next_page_token" through nested JSON objects. +fn get_nested_str<'a>(val: &'a Value, dotted_path: &str) -> Option<&'a str> { + let mut current = val; + for segment in dotted_path.split('.') { + current = current.get(segment)?; + } + current.as_str() +} + +/// Resolve a dot-separated path (`data`, `result.items`, `users.0.name`) +/// against a JSON value, returning a reference to the addressed subvalue. +/// +/// Empty / pure-dot paths are treated as "no path" and return `None` so the +/// caller can decide between "use the whole value" and "this is an error". +/// A non-empty path that doesn't resolve also returns `None` — callers +/// that need to surface a user-facing error (like +/// `x-fern-sdk-return-value` extraction) check for that case and emit a +/// `CliError::Validation` explaining which path missed. +/// +/// Segments are matched against object keys (`Value::get(&str)`); a +/// segment that parses as a non-negative integer additionally indexes +/// into arrays at the corresponding position (`Value::get(usize)`). +/// Object-key lookup wins when the same segment is ambiguous — JSON +/// object keys can be the literal string `"0"`, and surfacing the +/// matching key is what a user reading the spec expects. Falling back +/// to array indexing only when the value is actually an array keeps +/// the dot-path grammar a strict superset of upstream's +/// `RESPONSE_PROPERTY` (object-only) behavior. +fn get_nested_value<'a>(val: &'a Value, dotted_path: &str) -> Option<&'a Value> { + let trimmed = dotted_path.trim(); + if trimmed.is_empty() { + return None; + } + let mut current = val; + for segment in trimmed.split('.') { + if segment.is_empty() { + return None; + } + // Object-key lookup first, then numeric-array-index fallback so + // an object with a literal `"0"` key still resolves there. The + // array path only triggers when the current value is actually + // a JSON array — otherwise the segment was meant as an object + // key and was simply missing, which the `?` propagates. + if let Some(next) = current.get(segment) { + current = next; + continue; + } + if current.is_array() { + if let Ok(idx) = segment.parse::() { + current = current.get(idx)?; + continue; + } + } + return None; + } + Some(current) +} + +/// Apply `x-fern-sdk-return-value` extraction to a single response value. +/// +/// `return_path` is the dot-separated key path declared by the spec (e.g. +/// `data`, `result.items`). When the path resolves, the addressed subvalue +/// is returned for downstream printing / capture. A non-empty path that +/// resolves to JSON `null` is preserved as `Value::Null` (the field was +/// in the response, just null — typed SDKs surface this identically). +/// A path that fails to resolve *at all* (missing key, intermediate +/// non-object, out-of-range index) is a hard error — the spec promised +/// that subvalue and the server didn't deliver it. `no_extract = true` +/// bypasses the extraction entirely so callers (typically via +/// `--no-extract`) can see the full response for debugging. +/// +/// TODO(error-variant): `CliError::Validation` is the closest existing +/// variant but conceptually this is *response-contract* validation, not +/// input validation. Worth introducing a `CliError::ResponseContract` +/// variant once another response-side validation error needs the same +/// classification. +fn extract_return_value( + body: &Value, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + match return_path { + Some(path) if !no_extract && !path.trim().is_empty() => { + match get_nested_value(body, path) { + Some(v) => Ok(v.clone()), + None => Err(CliError::Validation(format!( + "x-fern-sdk-return-value path '{path}' did not resolve in response for \ + operation {method_descriptor}. Pass --no-extract to see the full response." + ))), + } + } + _ => Ok(body.clone()), + } +} + +/// Resolve the offset-pagination `step` value used for the +/// "did we get a full page?" check that gates pagination on short pages. +/// +/// `step_field` is the post-prefix-stripped field name from the spec (e.g. +/// `step: $request.limit` becomes `"limit"`). Resolution order: +/// +/// 1. Look up the field name in the request's outgoing query params and +/// parse the value as an integer — the canonical `$request.` +/// interpretation, matching fern-api/fern's SDK generators. +/// 2. If the field is itself a parseable integer literal (e.g. `step: "50"`), +/// use that. +/// 3. Otherwise return `None` — the caller falls back to the legacy +/// `items.len() > 0` check. +/// +/// Mirrors upstream `fern-api/fern`'s SDK generators: the step value is +/// used **only** for the `hasNextPage` full-page comparison +/// (`items.length >= step`) — never as the increment amount. The increment +/// is always `len(items)` in item-index semantics, which is what the +/// executor's offset loop already does. See: +/// - `generators/python/.../client_generator/pagination/offset.py` +/// - `generators/typescript/.../GeneratedThrowingEndpointResponse.ts` +fn resolve_step_target( + step_field: Option<&str>, + request_query_params: &[(String, String)], +) -> Option { + let name = step_field?; + if let Some((_, value)) = request_query_params.iter().find(|(k, _)| k == name) { + if let Ok(parsed) = value.parse::() { + return Some(parsed); + } + } + name.parse::().ok() +} + +/// Resolve a `next_path` value from `x-fern-pagination` against the URL of +/// the request that produced it. Mirrors browser-style URL resolution: +/// absolute URLs (`https://…`) replace the base; absolute paths (`/foo`) +/// keep the scheme + host; relative paths inherit the base's directory. +fn resolve_next_path(base_url: &str, next_path: &str) -> Result { + let base = reqwest::Url::parse(base_url) + .map_err(|e| format!("base URL `{base_url}` is not a valid URL: {e}"))?; + let resolved = base + .join(next_path) + .map_err(|e| format!("could not join next_path `{next_path}` to `{base_url}`: {e}"))?; + Ok(resolved.to_string()) +} + +/// Handle a JSON response: parse, output, and check pagination. +/// Returns `Ok(true)` if the pagination loop should continue. +/// +/// `return_path` is the operation's resolved `x-fern-sdk-return-value` +/// extension (a dot-separated key path into the JSON body). When set and +/// `no_extract` is false, only the addressed subvalue is printed / +/// captured — but the full response is still used for pagination +/// continuation checks, since pagination paths (`next_cursor`, +/// `results`, …) are declared relative to the whole body and would +/// silently break if extracted away. +#[allow(clippy::too_many_arguments)] +async fn handle_json_response( + body_text: &str, + pagination: &PaginationConfig, + endpoint_pag: Option<&EndpointPagination>, + pipeline: &crate::formatter::OutputPipeline, + pages_fetched: &mut u32, + page_state: &mut PageState, + capture_output: bool, + captured: &mut Vec, + request_url: &str, + request_query_params: &[(String, String)], + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, + pager: &mut Option, +) -> Result { + if let Ok(json_val) = serde_json::from_str::(body_text) { + let output_val = + extract_return_value(&json_val, return_path, no_extract, method_descriptor)?; + + *pages_fetched += 1; + + // The three branches below are mutually exclusive (one consumes + // `output_val`), so the unconditional move into `captured.push` + // is safe. If a future change adds a side-effect that also + // needs `output_val` outside this if/else chain, the compiler + // will flag it — clone there rather than reintroducing a + // speculative `.clone()` here. + if capture_output { + captured.push(output_val); + } else if pagination.page_all { + let is_first_page = *pages_fetched == 1; + if let Some(ref mut pager_handle) = pager { + pipeline + .emit(pager_handle, &output_val, true, is_first_page) + .context("Failed to write output")?; + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &output_val, true, is_first_page) + .context("Failed to write output")?; + } + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &output_val, false, true) + .context("Failed to write output")?; + } + + // Check whether to fetch a next page. Per-op `x-fern-pagination` + // overrides the document heuristic when present. + if pagination.page_all && *pages_fetched < pagination.page_limit { + let should_continue = match endpoint_pag { + Some(EndpointPagination::Cursor { next_cursor, .. }) => { + match get_nested_str(&json_val, next_cursor) { + Some(token) if !token.is_empty() => { + *page_state = PageState::Cursor(Some(token.to_string())); + true + } + _ => false, + } + } + Some(EndpointPagination::Offset { + results, + has_next_page, + step, + .. + }) => { + let still_more = match has_next_page { + Some(path) => json_val + .pointer(&format!("/{}", path.replace('.', "/"))) + .and_then(Value::as_bool) + .unwrap_or(true), + None => true, + }; + let page_size = json_val + .pointer(&format!("/{}", results.replace('.', "/"))) + .and_then(Value::as_array) + .map(|a| a.len() as u64) + .unwrap_or(0); + // When `step` is wired, gate the next page on whether + // the server returned a *full* page. Matches upstream + // fern-api/fern's `items.length >= step` check — a + // server returning a short page signals end-of-data + // even if `has_next_page` was omitted, preventing the + // executor from over-advancing past the last record. + let got_full_page = + match resolve_step_target(step.as_deref(), request_query_params) { + Some(target) => page_size >= target, + None => page_size > 0, + }; + if still_more && got_full_page { + let current = match page_state { + PageState::Offset(n) => *n, + _ => 0, + }; + // Advance by the number of items actually returned + // — item-index semantics, matching upstream's + // default `offsetSemantics`. The `step` field + // controls only the full-page gate above, not the + // increment amount. + *page_state = PageState::Offset(current + page_size); + true + } else { + false + } + } + Some(EndpointPagination::Uri { next_uri, .. }) => { + match get_nested_str(&json_val, next_uri) { + Some(url) if !url.is_empty() => { + // The response chooses the next request's URL, so it + // must not be able to steer it off-host and take the + // credential with it. + let base = page_state + .url_override() + .unwrap_or(request_url) + .to_string(); + match crate::http::check_pagination_target( + &pagination.cli_name, + &base, + url, + ) { + Ok(()) => { + *page_state = PageState::NextUrl(Some(url.to_string())); + true + } + Err(e) => { + tracing::warn!( + next_uri = %url, + base_url = %base, + error = %e, + "refusing x-fern-pagination next_uri; halting pagination" + ); + false + } + } + } + _ => false, + } + } + Some(EndpointPagination::Path { next_path, .. }) => { + match get_nested_str(&json_val, next_path) { + Some(path) if !path.is_empty() => { + // Resolve relative paths (e.g. `/v1/things?cursor=…`) + // against the previous request's URL so the host + // + scheme are preserved across pages. + let base = page_state + .url_override() + .unwrap_or(request_url) + .to_string(); + match resolve_next_path(&base, path).and_then(|resolved| { + // `next_path` may be an absolute URL, which + // replaces the base's origin — so the resolved + // target needs the same host check as the `Uri` + // variant. Checked after resolution so a relative + // path is judged on what it actually resolves to. + crate::http::check_pagination_target( + &pagination.cli_name, + &base, + &resolved, + ) + .map(|()| resolved) + }) { + Ok(resolved) => { + *page_state = PageState::NextUrl(Some(resolved)); + true + } + Err(e) => { + tracing::warn!( + next_path = %path, + base_url = %base, + error = %e, + "failed to resolve x-fern-pagination next_path; halting pagination" + ); + false + } + } + } + _ => false, + } + } + // Custom: caller-driven. The executor never auto-continues; + // it issues exactly one request, surfaces the `results` + // selection like the others, and stops. + Some(EndpointPagination::Custom { .. }) => false, + None => match get_nested_str(&json_val, &pagination.token_response_path) { + Some(token) if !token.is_empty() => { + *page_state = PageState::Cursor(Some(token.to_string())); + true + } + _ => false, + }, + }; + + if should_continue { + if pagination.page_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + pagination.page_delay_ms, + )) + .await; + } + return Ok(true); + } + } + } else if !capture_output && !pipeline.quiet && !body_text.is_empty() { + println!("{body_text}"); + } + + Ok(false) +} + +/// Handle a binary response by streaming it to a file (or to stdout when +/// `output_path == Some("-")`, the curl/wget stdout sentinel). +async fn handle_binary_response( + response: reqwest::Response, + content_type: &str, + output_path: Option<&str>, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, +) -> Result, CliError> { + // `--output -` pipes raw bytes to stdout and skips both the disk write + // and the success-metadata JSON — so the body is consumable downstream + // (e.g. ` ... --output - | ffplay -` for audio responses, + // `... | tar x` for archives). The validator in binding.rs treats `-` + // as a sentinel and does NOT canonicalize it to `cwd/-`, so we receive + // the literal here. + // + // We use std::io::stdout (sync) rather than tokio::io::stdout because + // tokio's stdout writer routes through a per-runtime blocking worker + // that can be left undrained when a one-shot CLI tears down the runtime + // after this return, causing the process to wedge before exit. The + // metadata-emit path further down already uses std::io::stdout in the + // same async context, so mixing is fine. + if output_path == Some("-") { + use std::io::Write; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + // We re-acquire the stdout lock per chunk because StdoutLock is + // !Send and cannot be held across the await above (the binding + // adapter returns a Send-required boxed future). + std::io::stdout() + .write_all(&chunk) + .context("Failed to write to stdout")?; + } + std::io::stdout() + .flush() + .context("Failed to flush stdout")?; + return Ok(None); + } + + let file_path = if let Some(p) = output_path { + PathBuf::from(p) + } else if let Some(name) = response + .headers() + .get(reqwest::header::CONTENT_DISPOSITION) + .and_then(|v| v.to_str().ok()) + .and_then(extract_content_disposition_filename) + { + // The server named the file via RFC 6266 Content-Disposition — use + // it. The helper has already reduced the value to its safe basename + // so the server can never pick the output directory. + PathBuf::from(name) + } else { + let ext = mime_to_extension(content_type); + PathBuf::from(format!("download.{ext}")) + }; + + let mut file = create_file_no_follow(&file_path).await?; + + let mut stream = response.bytes_stream(); + let mut total_bytes: u64 = 0; + + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + file.write_all(&chunk) + .await + .context("Failed to write to file")?; + total_bytes += chunk.len() as u64; + } + + file.flush().await.context("Failed to flush file")?; + + let result = json!({ + "status": "success", + "saved_file": file_path.display().to_string(), + "mimeType": content_type, + "bytes": total_bytes, + }); + + if capture_output { + return Ok(Some(result)); + } + + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &result, false, true) + .context("Failed to write output")?; + + Ok(None) +} + +// --------------------------------------------------------------------------- +// x-fern-streaming response handling. +// +// Two entry points: +// - `stream_response` — consume the response body line-by-line and emit +// each event to stdout as it arrives. Used by the default CLI path +// (no `--no-stream`, not `capture_output`). +// - `buffer_streaming_response` — collect every event into one JSON value +// (single object when only one event arrived, array otherwise) and +// return it for downstream printing / capture. Used when the caller +// passed `--no-stream` (pretty-print to stdout) or is a programmatic +// `AppContext::invoke` caller that needs a typed value back. +// +// Line decoding is delegated to `decode_stream_event`, which is a pure +// function over (config, raw_line) — exercised directly by unit tests +// without spinning up a wiremock server. +// --------------------------------------------------------------------------- + +/// Outcome of decoding a single raw stream line. +#[derive(Debug, PartialEq, Eq)] +enum StreamEvent { + /// A complete event payload was decoded (post-`data:` strip for + /// SSE; the line verbatim for NDJSON). The caller emits this. + Event(String), + /// The line was framing-only and carries no payload (blank lines, + /// `event:`/`id:`/`retry:` SSE field lines, SSE comments starting + /// with `:`, or an empty JSON line). Skip and keep reading. + Skip, + /// The terminator sentinel was reached. The caller stops reading. + Terminate, +} + +/// Decode a single raw line of streaming response body against the +/// configured wire format. Pure / synchronous so unit tests can hit +/// every decoding branch (with and without `data:` prefix, comment +/// lines, terminator handling) without setting up a mock HTTP server. +/// +/// The `line` is expected to already have its trailing newline / CR +/// stripped — the caller (the line-reading loop) handles framing. +/// +/// Only the line-at-a-time formats (NDJSON, text) flow through here. +/// SSE framing is stateful (multi-line `data:` payloads are joined +/// with `\n` and dispatched on a blank-line separator per the WHATWG +/// spec), so the SSE path uses [`SseLineDecoder`] instead. +fn decode_stream_event(config: &StreamingConfig, line: &str) -> StreamEvent { + match config { + StreamingConfig::Sse { .. } => { + // SSE is decoded statefully via `SseLineDecoder`; reaching + // this arm is a bug in the caller. + debug_assert!(false, "SSE lines must flow through SseLineDecoder"); + StreamEvent::Skip + } + StreamingConfig::Json { terminator } => { + // NDJSON / JSONL framing: empty lines are skipped (some + // servers emit blank keepalive lines between records). + if line.is_empty() { + return StreamEvent::Skip; + } + + if let Some(sentinel) = terminator.as_deref() { + if line == sentinel { + return StreamEvent::Terminate; + } + } + + StreamEvent::Event(line.to_string()) + } + StreamingConfig::Text => { + // Plain-text line stream: empty lines are dropped per the + // C# generator (`if(!string.IsNullOrEmpty(line)) yield + // return line` — see `HttpEndpointGenerator.ts:815-825`). + // No JSON parse, no SSE prefix strip, no terminator. + if line.is_empty() { + return StreamEvent::Skip; + } + StreamEvent::Event(line.to_string()) + } + } +} + +/// Stateful SSE event accumulator. Buffers `data:` payloads across +/// multiple lines (joined with `\n` per the WHATWG SSE spec +/// ) +/// and dispatches the joined payload as one event on a blank-line +/// separator or at stream EOF. Mirrors the TS runtime's +/// `iterSseEvents` loop in +/// `generators/typescript/utils/core-utilities/src/core/stream/Stream.template.ts:123-165`. +/// +/// Unknown SSE field lines (`id:`, `retry:`, or anything else) are +/// ignored per spec; `event:` is tracked across the same event +/// boundary for parity even though the CLI surface does not yet +/// route on it (no `eventDiscriminator` support — a deliberate +/// non-feature, left out of this parity sweep). +#[derive(Default)] +struct SseLineDecoder { + data_buf: Option, + event_type: Option, +} + +impl SseLineDecoder { + /// Process one raw line. Returns `Some(payload)` when a blank + /// line dispatches a buffered event (the joined `data:` + /// payload); `None` otherwise. + fn push_line(&mut self, line: &str) -> Option { + if line.is_empty() { + // Blank line: dispatch the buffered event if any, then + // reset event_type either way (matches TS, which clears + // both fields on dispatch regardless of whether one was + // actually emitted). + let dispatched = self.data_buf.take(); + self.event_type = None; + return dispatched; + } + if line.starts_with(':') { + // SSE comment / heartbeat — framing only, no payload. + return None; + } + if let Some(rest) = line.strip_prefix("event:") { + self.event_type = Some(rest.trim().to_string()); + return None; + } + if let Some(rest) = line.strip_prefix("data:") { + // Strip exactly one optional leading space per the SSE + // spec ("If value starts with a U+0020 SPACE, remove it"). + let val = rest.strip_prefix(' ').unwrap_or(rest); + match &mut self.data_buf { + Some(buf) => { + buf.push('\n'); + buf.push_str(val); + } + None => { + self.data_buf = Some(val.to_string()); + } + } + return None; + } + // Unknown SSE fields (`id:`, `retry:`, anything else) are + // ignored per spec. + None + } + + /// Flush the final partial event at stream EOF. Mirrors the TS + /// runtime's post-loop `if (dataValue != null) yield ...` block + /// — servers commonly close the connection without a trailing + /// blank line on the last event. + fn flush(&mut self) -> Option { + let dispatched = self.data_buf.take(); + self.event_type = None; + dispatched + } +} + +/// Apply `x-fern-sdk-return-value` to a decoded event payload. Each +/// event is parsed as JSON, the configured path is projected, and the +/// printable form (a JSON-encoded string) is returned. When the JSON +/// fails to parse (servers occasionally emit a non-JSON keepalive +/// frame), the raw event string is emitted verbatim so the caller can +/// still see what came over the wire. +/// +/// Text streams ([`StreamingConfig::Text`]) bypass this projection +/// entirely — their event payload is a raw line, not a JSON value, +/// so `x-fern-sdk-return-value` and `--no-extract` are both no-ops +/// (mirrors the C# generator, which `yield return line` directly). +fn project_stream_event( + streaming: &StreamingConfig, + event_payload: &str, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + if matches!(streaming, StreamingConfig::Text) { + return Ok(Value::String(event_payload.to_string())); + } + match serde_json::from_str::(event_payload) { + Ok(parsed) => extract_return_value(&parsed, return_path, no_extract, method_descriptor), + // Bare strings, numbers, or partial frames flow through as + // strings so the caller's output stream isn't blocked by + // upstream noise. The user can `--no-extract` to inspect the + // raw frames when debugging unexpected shapes. + Err(_) => Ok(Value::String(event_payload.to_string())), + } +} + +/// Stream the response body line-by-line, emitting one formatted event +/// per dispatched payload to stdout. Stops at the configured +/// terminator (when the spec declared one) or at end-of-body. +/// +/// When a `--query` expression is set on the pipeline, each event is +/// projected through the JMESPath expression before formatting. Events +/// whose projection evaluates to `null` are suppressed, enabling +/// `--query` as a per-event streaming filter. +async fn stream_response( + response: reqwest::Response, + streaming: &StreamingConfig, + return_path: Option<&str>, + no_extract: bool, + pipeline: &crate::formatter::OutputPipeline, + method_descriptor: &str, +) -> Result<(), CliError> { + read_stream_events(response, streaming, |payload| { + let value = project_stream_event( + streaming, + &payload, + return_path, + no_extract, + method_descriptor, + )?; + // When no --query is set, skip the clone + projection entirely. + if pipeline.query.is_none() { + let mut out = std::io::stdout().lock(); + pipeline + .emit_raw(&mut out, &value, false, true) + .context("Failed to write output")?; + } else if let Some(projected) = pipeline + .apply_query_streaming(&value) + .context("--query evaluation failed")? + { + let mut out = std::io::stdout().lock(); + pipeline + .emit_raw(&mut out, &projected, false, true) + .context("Failed to write output")?; + } + Ok(()) + }) + .await +} + +/// Buffer the streaming response into a single JSON value: a lone event +/// is returned as-is so downstream consumers see the unary shape; two +/// or more events are collected into a JSON array. An empty stream +/// returns `Value::Null` — the body finished without emitting any +/// payload, which is what the typed SDKs surface back to callers. +async fn buffer_streaming_response( + response: reqwest::Response, + streaming: &StreamingConfig, + return_path: Option<&str>, + no_extract: bool, + method_descriptor: &str, +) -> Result { + let mut events: Vec = Vec::new(); + read_stream_events(response, streaming, |payload| { + events.push(project_stream_event( + streaming, + &payload, + return_path, + no_extract, + method_descriptor, + )?); + Ok(()) + }) + .await?; + Ok(match events.len() { + 0 => Value::Null, + 1 => events.into_iter().next().unwrap(), + _ => Value::Array(events), + }) +} + +/// Drive the response body through the format-appropriate line +/// decoder, invoking `emit` for each dispatched event payload. SSE +/// uses [`SseLineDecoder`] (stateful multi-line `data:` buffering); +/// NDJSON and text use [`decode_stream_event`] line-by-line. The +/// configured terminator (if any) is checked here, before `emit`, so +/// callers don't need to know about format-specific framing rules. +async fn read_stream_events( + response: reqwest::Response, + streaming: &StreamingConfig, + mut emit: F, +) -> Result<(), CliError> +where + F: FnMut(String) -> Result<(), CliError>, +{ + let mut line_stream = ResponseLineStream::new(response); + match streaming { + StreamingConfig::Sse { terminator } => { + let mut decoder = SseLineDecoder::default(); + while let Some(line) = line_stream.next_line().await? { + if let Some(payload) = decoder.push_line(&line) { + if let Some(sentinel) = terminator.as_deref() { + if payload == sentinel { + return Ok(()); + } + } + emit(payload)?; + } + } + // EOF: flush any final unterminated event — matches the + // TS runtime's post-loop dispatch (see Stream.template.ts). + if let Some(payload) = decoder.flush() { + if let Some(sentinel) = terminator.as_deref() { + if payload == sentinel { + return Ok(()); + } + } + emit(payload)?; + } + Ok(()) + } + StreamingConfig::Json { .. } | StreamingConfig::Text => { + while let Some(line) = line_stream.next_line().await? { + match decode_stream_event(streaming, &line) { + StreamEvent::Skip => continue, + StreamEvent::Terminate => return Ok(()), + StreamEvent::Event(payload) => emit(payload)?, + } + } + Ok(()) + } + } +} + +/// Adapt a `reqwest::Response`'s byte stream into a line iterator. Keeps +/// a small in-memory buffer of bytes received but not yet terminated +/// by a newline; reads stop at LF and emit the preceding bytes (CR is +/// also stripped) as a UTF-8 string. The terminating line of a +/// response that doesn't end with a newline is still emitted from +/// `next_line` before the stream returns `None`. +struct ResponseLineStream { + stream: futures_util::stream::BoxStream<'static, reqwest::Result>, + buf: Vec, + done: bool, +} + +impl ResponseLineStream { + fn new(response: reqwest::Response) -> Self { + Self { + stream: Box::pin(response.bytes_stream()), + buf: Vec::with_capacity(4096), + done: false, + } + } + + async fn next_line(&mut self) -> Result, CliError> { + loop { + // Emit a buffered line if a newline has already been received. + if let Some(idx) = self.buf.iter().position(|&b| b == b'\n') { + let mut line: Vec = self.buf.drain(..=idx).collect(); + line.pop(); // drop the trailing '\n' + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(decode_line_lossy(line))); + } + + // If the stream is exhausted, flush any trailing bytes that + // didn't end with a newline (servers commonly omit the final + // newline on the last event of an NDJSON stream). + if self.done { + if self.buf.is_empty() { + return Ok(None); + } + let mut line: Vec = std::mem::take(&mut self.buf); + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok(Some(decode_line_lossy(line))); + } + + // Pull the next chunk off the wire. + match self.stream.next().await { + Some(Ok(chunk)) => self.buf.extend_from_slice(&chunk), + Some(Err(err)) => { + return Err(anyhow::Error::from(err) + .context("Failed to read streaming response chunk") + .into()); + } + None => self.done = true, + } + } + } +} + +/// Decode a single line as UTF-8, replacing invalid sequences with +/// U+FFFD so a malformed byte (e.g. truncated multibyte from a flaky +/// proxy) doesn't crash the stream. +fn decode_line_lossy(bytes: Vec) -> String { + match String::from_utf8(bytes) { + Ok(s) => s, + Err(e) => String::from_utf8_lossy(&e.into_bytes()).into_owned(), + } +} + +/// Executes an API method call. +/// +/// This is the core function of the CLI that handles: +/// 1. Parameter validation and URL construction. +/// 2. Request body validation against the Discovery Document schema. +/// 3. Authentication (OAuth or none). +/// 4. Sending the HTTP request (GET/POST/etc). +/// 5. Handling various response types (JSON, binary). +/// 6. Auto-pagination for list endpoints. +#[allow(clippy::too_many_arguments)] +pub async fn execute_method( + doc: &RestDescription, + method: &RestMethod, + params_json: Option<&str>, + body_json: Option<&str>, + auth_provider: &DynAuthProvider, + output_path: Option<&str>, + upload: Option>, + binary_body_path: Option<&str>, + multipart_parts: Option>, + dry_run: bool, + pagination: &PaginationConfig, + pipeline: &crate::formatter::OutputPipeline, + capture_output: bool, + base_url_override: Option<&str>, + http_config: &crate::http::HttpConfig, + no_extract: bool, + no_retry: bool, + no_stream: bool, + debug: bool, + extra_headers: &[(String, String)], + extra_global_params: &[crate::openapi::app::ResolvedGlobalParam], +) -> Result, CliError> { + let binary_flag = method + .binary_request_body + .as_ref() + .map(|b| b.flag_name.as_str()); + if binary_body_path.is_some() && binary_flag.is_none() { + return Err(CliError::Validation( + "binary body path is only valid for operations with a binary request body" + .to_string(), + )); + } + if binary_body_path.is_some() && body_json.is_some() { + return Err(CliError::Validation(format!( + "--{} and --json are mutually exclusive", + binary_flag.unwrap_or("file"), + ))); + } + + let input = parse_and_validate_inputs(doc, method, params_json, body_json, upload.is_some(), base_url_override, extra_headers, extra_global_params)?; + + // Human-readable identifier for the operation, used in + // `x-fern-sdk-return-value` extraction errors so the user can find + // the offending op when the response shape disagrees with the + // spec. Prefer the `operationId` (matches the spec text) and fall + // back to `GET /things` when it's absent. + let method_descriptor = match method.id.as_deref() { + Some(id) => format!("'{id}'"), + None => format!( + "{} {}", + method.http_method.to_ascii_uppercase(), + method.path + ), + }; + + if dry_run { + let content_type_header = if input.body.is_some() { + method.body_encoding.content_type() + } else { + "" + }; + // `--dry-run` prints the request it *would* send, so it must redact + // credentials for the same reason `--debug` does. A credential reaches + // `header_params` whenever the spec models it as a header parameter or + // an `x-fern-global-headers` entry (an `apiKey`-in-header scheme is the + // common case), and dry-run output is routinely pasted into issues. + // Same predicate and spec-derived names as the debug dump, so the two + // can't drift apart. + let sensitive_header_names = spec_sensitive_header_names(doc); + let redacted_headers: Vec<(String, String)> = input + .header_params + .iter() + .map(|(name, value)| { + if crate::debug::is_sensitive_header(name, &sensitive_header_names) { + (name.clone(), "[REDACTED]".to_string()) + } else { + (name.clone(), value.clone()) + } + }) + .collect(); + let mut dry_run_info = json!({ + "dry_run": true, + "url": input.full_url, + "method": method.http_method, + "query_params": input.query_params, + "headers": redacted_headers, + "body": input.body, + "is_multipart_upload": input.is_upload, + }); + if !content_type_header.is_empty() { + dry_run_info["content_type"] = json!(content_type_header); + } + if method.body_encoding.is_form() { + if let Some(ref body_val) = input.body { + dry_run_info["form_encoded_body"] = json!(encode_form_body(body_val)); + } + } + if let Some(raw) = binary_body_path { + let (content_type, flag_name) = method + .binary_request_body + .as_ref() + .map(|b| (b.content_type.as_str(), b.flag_name.as_str())) + .unwrap_or(("", "")); + let (source, transfer) = match BinaryBodySource::parse(raw) { + BinaryBodySource::File { path, mode } => { + let mode_str = match mode { + AtMode::Auto => "auto", + AtMode::Text => "text", + AtMode::Data => "data", + }; + ( + json!({ "file": path.as_ref(), "mode": mode_str }), + "content-length", + ) + } + BinaryBodySource::Stdin => (json!({ "stdin": true }), "chunked"), + }; + dry_run_info["binary_body"] = json!({ + "source": source, + "content_type": content_type, + "transfer_encoding": transfer, + "flag": flag_name, + }); + } + if let Some(ref parts) = multipart_parts { + let part_info: Vec = parts + .iter() + .map(|p| match p { + MultipartPart::Text { + name, + value, + content_type, + } => { + json!({ "name": name, "type": "text", "value": value, "content_type": content_type }) + } + MultipartPart::File { + name, + path, + content_type, + } => { + json!({ "name": name, "type": "file", "path": path, "content_type": content_type }) + } + }) + .collect(); + dry_run_info["multipart_form_data"] = json!(part_info); + } + if capture_output { + return Ok(Some(dry_run_info)); + } + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &dry_run_info, false, true) + .context("Failed to write output")?; + return Ok(None); + } + + let endpoint_pag = method.pagination.as_ref(); + let mut page_state: PageState = PageState::initial(endpoint_pag); + let mut pages_fetched: u32 = 0; + let mut captured_values = Vec::new(); + let auth_metadata = endpoint_metadata_for(method, base_url_override); + + // Spawn an external pager when --page-all is active on a TTY. + let fallback_label = format!( + "{} {}", + method.http_method.to_ascii_uppercase(), + method.path + ); + let pager_label = method.id.as_deref().unwrap_or(&fallback_label); + let mut pager_handle = if pagination.page_all && !pagination.no_pager && !capture_output { + let pager_config = crate::pager::PagerConfig::from_env(&pagination.cli_name); + crate::pager::spawn_pager(&pager_config, pager_label) + } else { + None + }; + + // Derive spec-declared sensitive names for the debug dump. + let additional_sensitive_headers: Vec<&str> = if debug { + spec_sensitive_header_names(doc) + } else { + Vec::new() + }; + let additional_sensitive_query_params: Vec<&str> = if debug { + doc.security_schemes + .values() + .filter_map(|s| { + if let crate::openapi::discovery::SecurityScheme::ApiKeyQuery { name } = s { + Some(name.as_str()) + } else { + None + } + }) + .collect() + } else { + Vec::new() + }; + + // Pre-compute the body string for the debug request dump. This is + // computed once here because the body doesn't change across retries. + let body_str_for_dump: Option = if debug + && binary_body_path.is_none() + && !input.is_upload + && multipart_parts.is_none() + { + if method.body_encoding.is_form() { + input.body.as_ref().map(encode_form_body) + } else { + input.body.as_ref().map(|v| v.to_string()) + } + } else { + None + }; + + // Build the client once outside the pagination loop. Client construction + // reads env vars and (with TLS) builds a connection pool; rebuilding per + // page would defeat connection reuse and emit any one-time warnings + // (e.g. insecure-mode) once per page. + let client = http_config.build_client()?; + + loop { + // Snapshot the URL we are about to hit so the response handler can + // resolve relative `next_path` values against it. Captured before + // `page_state` is borrowed mutably below. + let current_url = page_state + .url_override() + .unwrap_or(&input.full_url) + .to_string(); + + let method_id = method.id.as_deref().unwrap_or("unknown"); + let start = std::time::Instant::now(); + + // Retry loop. Each iteration rebuilds the request (so streaming + // bodies start fresh) and dispatches it. `retry_attempt` is + // 0-indexed and counts *prior* sends — we increment it after + // each retry, then re-check the policy before the next send. + // + // Stdin-sourced binary bodies are *not* replayable: the first + // attempt consumes the pipe and any retry would silently send + // an empty body. Disable retries for that case so we preserve + // the pre-retry behavior (a single attempt, surface whatever + // the server returns) rather than masking the original failure. + // Disable retries when the body is a streamed stdin or multipart + // body — those can't be replayed on a second attempt. + let default_retries = RetriesConfig::default(); + let retries_cfg = + if binary_body_is_stdin(binary_body_path) || multipart_has_stdin(&multipart_parts) { + None + } else { + Some(method.retries.as_ref().unwrap_or(&default_retries)) + }; + + // Auto Idempotency-Key: generate once before the retry loop so + // the same key is sent on every attempt. Only for POST/PUT/PATCH + // unless opted out via `x-fern-cli-idempotency: false`, or when + // the operation already has an explicit idempotency-header + // mechanism (x-fern-idempotent: true provides --idempotency-key). + let user_provides_idempotency = method.idempotent + || input + .header_params + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("idempotency-key")); + let idempotency_key = if !method.no_auto_idempotency_key + && !user_provides_idempotency + && crate::http::needs_idempotency_key(&method.http_method) + { + Some(crate::http::generate_idempotency_key()) + } else { + None + }; + + let mut retry_attempt: u32 = 0; + let response = loop { + let mut request = build_http_request( + &client, + method, + &input, + auth_provider, + &auth_metadata, + &page_state, + pages_fetched, + &upload, + binary_body_path, + &multipart_parts, + pagination, + ) + .await?; + + if let Some(ref key) = idempotency_key { + request = request.header("Idempotency-Key", key.as_str()); + } + + let built = request.build().map_err(|e| { + CliError::Other(anyhow::Error::from(e).context("Failed to build HTTP request")) + })?; + if debug { + crate::debug::dump_request( + built.method().as_str(), + built.url().as_str(), + built.headers(), + body_str_for_dump.as_deref(), + &additional_sensitive_headers, + &additional_sensitive_query_params, + ); + } + match client.execute(built).await { + Ok(resp) => { + let status = resp.status(); + let retry_after_header = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + if let Some(cfg) = retries_cfg { + let outcome = RetryOutcome { + status: Some(status.as_u16()), + retry_after: retry_after_header.as_deref(), + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + cfg, + &method.http_method, + method.idempotent || idempotency_key.is_some(), + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + "retrying after retryable HTTP status", + ); + // Drain the body so the connection can be + // returned to the pool. We don't surface + // the body on retried responses; the final + // response (success or terminal failure) + // is what the user sees. + let _ = resp.bytes().await; + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + } + break resp; + } + Err(e) => { + // A refused redirect is a policy decision, not a transport + // blip: retrying re-issues a request that will be refused + // identically. Classify and return before `decide_retry`. + if let Some(err) = crate::http::redirect_refusal_error(&e) { + return Err(err); + } + if let Some(cfg) = retries_cfg { + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + cfg, + &method.http_method, + method.idempotent || idempotency_key.is_some(), + no_retry, + ) { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + attempt = retry_attempt + 1, + delay_ms = delay.as_millis() as u64, + error = %e, + "retrying after network/transport failure", + ); + tokio::time::sleep(delay).await; + retry_attempt += 1; + continue; + } + } + // Surface a human-readable hint to stderr if this looks like + // a TLS failure — the most common debugging hump for users + // behind corporate proxies / interception tools. The hint is + // a side effect; the error then propagates up like any other. + crate::http::maybe_emit_tls_hint(http_config, &e); + return Err(anyhow::Error::from(e).context("HTTP request failed").into()); + } + } + }; + let latency_ms = start.elapsed().as_millis() as u64; + + let status = response.status(); + let content_type = response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + if !status.is_success() { + tracing::warn!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + latency_ms = latency_ms, + "API error" + ); + // Raw mode: emit error body verbatim, return sentinel. + if pipeline.is_raw() && !capture_output { + if !pipeline.quiet { + let bytes = response.bytes().await.unwrap_or_default(); + let mut stdout = std::io::stdout().lock(); + let _ = std::io::Write::write_all(&mut stdout, &bytes); + let _ = std::io::Write::flush(&mut stdout); + } + return Err(CliError::RawSentinel { + code: status.as_u16(), + }); + } + // HTTP mode: emit status line + headers + body, return sentinel. + if pipeline.is_http() && !capture_output { + if !pipeline.quiet { + let version = response.version(); + let resp_headers = response.headers().clone(); + let bytes = response.bytes().await.unwrap_or_default(); + let mut stdout = std::io::stdout().lock(); + let _ = write_http_preamble(&mut stdout, version, status, &resp_headers); + let _ = std::io::Write::write_all(&mut stdout, &bytes); + let _ = std::io::Write::flush(&mut stdout); + } + return Err(CliError::RawSentinel { + code: status.as_u16(), + }); + } + let response_headers = response.headers().clone(); + let error_body = response.text().await.unwrap_or_default(); + if debug { + crate::debug::dump_error_response( + status.as_u16(), + latency_ms, + &response_headers, + &error_body, + &additional_sensitive_headers, + ); + } + return handle_error_response( + status, + &error_body, + auth_provider.as_ref(), + &auth_metadata, + ); + } + + tracing::debug!( + api_method = method_id, + http_method = %method.http_method, + status = status.as_u16(), + latency_ms = latency_ms, + content_type = %content_type, + is_upload = input.is_upload, + page = pages_fetched, + "API request" + ); + + // Raw mode: stream response bytes to stdout verbatim. + if pipeline.is_raw() && !capture_output { + if pipeline.quiet { + let _ = response.bytes().await; + } else { + use std::io::Write; + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + // StdoutLock is !Send — re-acquire per chunk. + std::io::stdout() + .lock() + .write_all(&chunk) + .context("Failed to write to stdout")?; + } + std::io::stdout() + .flush() + .context("Failed to flush stdout")?; + } + break; + } + + // HTTP mode: emit status line + headers + raw body. + if pipeline.is_http() && !capture_output { + if pipeline.quiet { + let _ = response.bytes().await; + } else { + use std::io::Write; + let version = response.version(); + let resp_headers = response.headers().clone(); + // Write preamble in its own scope so StdoutLock (!Send) is + // dropped before the streaming await below. + { + let mut stdout = std::io::stdout().lock(); + write_http_preamble(&mut stdout, version, status, &resp_headers) + .context("Failed to write HTTP preamble")?; + } + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("Failed to read response chunk")?; + // StdoutLock is !Send — re-acquire per chunk. + std::io::stdout() + .lock() + .write_all(&chunk) + .context("Failed to write to stdout")?; + } + std::io::stdout() + .flush() + .context("Failed to flush stdout")?; + } + break; + } + + // Streaming response branch. Selected when: + // - the operation declares `x-fern-streaming`, AND + // - the caller hasn't explicitly opted out via `--no-stream`, + // AND + // - we aren't capturing into a single `Value` for a + // programmatic caller (those need a unary shape and treat + // `--no-stream` as implicit). + // + // `--no-stream` and `capture_output` both fall through to the + // existing buffered path below: the body is read once and + // either pretty-printed (no_stream from the CLI) or decoded + // into a `Value` (capture_output from `AppContext::invoke`). + if let Some(streaming) = method.streaming.as_ref() { + if !no_stream && !capture_output { + // Note: `pages_fetched` is intentionally left untouched + // here. Streaming endpoints are single-request by + // construction (see the parse-time mutual exclusion + // with `x-fern-pagination`), so the pagination loop + // never re-enters; bumping the counter would only + // confuse the unrelated request-tracing in `debug!`. + if debug { + crate::debug::dump_streaming_note( + status.as_u16(), + response.headers(), + &additional_sensitive_headers, + ); + } + stream_response( + response, + streaming, + method.return_value.as_deref(), + no_extract, + pipeline, + &method_descriptor, + ) + .await?; + break; + } + // Buffered fallback: collect every event into a single + // JSON array (or unwrap the lone event when only one + // arrived) so the downstream printer / capture path sees + // the kind of value it expects from a unary endpoint. The + // server may legitimately send a non-streaming body, so we + // still parse it line-by-line and fall back to a + // single-value array when the body holds one JSON object. + let buffered = buffer_streaming_response( + response, + streaming, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + ) + .await?; + if capture_output { + captured_values.push(buffered); + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &buffered, false, true) + .context("Failed to write output")?; + } + break; + } + + // SSE auto-detection: when the spec omits `x-fern-streaming` but + // the server responds with `text/event-stream`, treat the body as + // an SSE stream using the same infrastructure. This avoids falling + // through to the binary handler (which would dump the stream to a + // file or hang). + if content_type.contains("text/event-stream") && method.streaming.is_none() { + let sse_config = StreamingConfig::Sse { terminator: None }; + if !no_stream && !capture_output { + if debug { + crate::debug::dump_streaming_note( + status.as_u16(), + response.headers(), + &additional_sensitive_headers, + ); + } + stream_response( + response, + &sse_config, + method.return_value.as_deref(), + no_extract, + pipeline, + &method_descriptor, + ) + .await?; + break; + } + let buffered = buffer_streaming_response( + response, + &sse_config, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + ) + .await?; + if capture_output { + captured_values.push(buffered); + } else { + let mut out = std::io::stdout().lock(); + pipeline + .emit(&mut out, &buffered, false, true) + .context("Failed to write output")?; + } + break; + } + + let is_json = + content_type.contains("application/json") || content_type.contains("text/json"); + + if is_json || content_type.is_empty() { + let response_headers = response.headers().clone(); + let body_text = response + .text() + .await + .context("Failed to read response body")?; + + if debug { + crate::debug::dump_response( + status.as_u16(), + latency_ms, + &response_headers, + &body_text, + &additional_sensitive_headers, + ); + } + let response_body = body_text; + let should_continue = handle_json_response( + &response_body, + pagination, + endpoint_pag, + pipeline, + &mut pages_fetched, + &mut page_state, + capture_output, + &mut captured_values, + ¤t_url, + &input.query_params, + method.return_value.as_deref(), + no_extract, + &method_descriptor, + &mut pager_handle, + ) + .await?; + + if should_continue { + continue; + } + } else { + let bin_headers = response.headers().clone(); + if debug { + crate::debug::dump_streaming_note( + status.as_u16(), + &bin_headers, + &additional_sensitive_headers, + ); + } + if let Some(res) = handle_binary_response( + response, + &content_type, + output_path, + pipeline, + capture_output, + ) + .await? + { + captured_values.push(res); + } + } + + break; + } + + // Close the pager pipe and wait for it to exit before returning. + drop(pager_handle); + + if capture_output && !captured_values.is_empty() { + if captured_values.len() == 1 { + return Ok(Some(captured_values.pop().unwrap())); + } else { + return Ok(Some(Value::Array(captured_values))); + } + } + + Ok(None) +} + +/// Format an HTTP version enum as a string (e.g. `HTTP/1.1`). +fn format_http_version(version: reqwest::Version) -> &'static str { + match version { + reqwest::Version::HTTP_09 => "HTTP/0.9", + reqwest::Version::HTTP_10 => "HTTP/1.0", + reqwest::Version::HTTP_11 => "HTTP/1.1", + reqwest::Version::HTTP_2 => "HTTP/2", + reqwest::Version::HTTP_3 => "HTTP/3", + _ => "HTTP/1.1", + } +} + +/// Write the HTTP status line and headers preamble to `out`. +/// +/// Produces output like: +/// ```text +/// HTTP/1.1 200 OK\r\n +/// Content-Type: application/json\r\n +/// \r\n +/// ``` +fn write_http_preamble( + out: &mut dyn std::io::Write, + version: reqwest::Version, + status: reqwest::StatusCode, + headers: &reqwest::header::HeaderMap, +) -> std::io::Result<()> { + let version_str = format_http_version(version); + let reason = status.canonical_reason().unwrap_or(""); + write!(out, "{} {} {}\r\n", version_str, status.as_u16(), reason)?; + for (name, value) in headers.iter() { + let val_str = value.to_str().unwrap_or(""); + write!(out, "{}: {}\r\n", name, val_str)?; + } + write!(out, "\r\n")?; + Ok(()) +} + +/// Serialize a query parameter value according to its OpenAPI style. +fn serialize_query_param( + key: &str, + value: &Value, + param_def: Option<&crate::openapi::discovery::MethodParameter>, +) -> Vec<(String, String)> { + let style = param_def + .and_then(|p| p.style.as_deref()) + .unwrap_or("form"); + let explode = param_def + .and_then(|p| p.explode) + .unwrap_or(style == "form"); + + match style { + "deepObject" => serialize_deep_object(key, value), + // spaceDelimited / pipeDelimited only define array behavior; the + // elements are joined by a single space / pipe under one key. For + // non-array values they degrade to the same scalar shape as form. + "spaceDelimited" => serialize_delimited(key, value, ' '), + "pipeDelimited" => serialize_delimited(key, value, '|'), + _ => serialize_form(key, value, explode), + } +} + +/// `spaceDelimited` / `pipeDelimited` array serialization: a single key whose +/// value is the elements joined by `delim`. The delimiter is a literal here; +/// the request encoder percent-encodes it on the wire (space -> `%20`, +/// pipe -> `%7C`). +fn serialize_delimited(key: &str, value: &Value, delim: char) -> Vec<(String, String)> { + match value { + Value::Array(arr) => { + let joined = arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(&delim.to_string()); + vec![(key.to_string(), joined)] + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn serialize_deep_object(key: &str, value: &Value) -> Vec<(String, String)> { + match value { + Value::Object(_) => { + // Wrap as {key: value} so serde-qs produces key[...]=... pairs. + // ArrayFormat::Unindexed gives filter[tags]=a&filter[tags]=b, + // consistent with the Fern Python and C# SDKs. + let wrapped = serde_json::json!({ key: value }); + let config = serde_qs::Config::new() + .array_format(serde_qs::ArrayFormat::Unindexed); + match config.serialize_string(&wrapped) { + Ok(qs) => { + // serde-qs URL-encodes the output; decode each pair + qs.split('&') + .filter(|s| !s.is_empty()) + .filter_map(|pair| { + let (k, v) = pair.split_once('=')?; + let decoded_k = percent_encoding::percent_decode_str(k) + .decode_utf8_lossy() + .into_owned(); + let decoded_v = percent_encoding::percent_decode_str(v) + .decode_utf8_lossy() + .into_owned(); + Some((decoded_k, decoded_v)) + }) + .collect() + } + Err(_) => vec![(key.to_string(), value_to_query_string(value))], + } + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn serialize_form(key: &str, value: &Value, explode: bool) -> Vec<(String, String)> { + match value { + Value::Array(arr) if explode => arr + .iter() + .map(|v| (key.to_string(), value_to_query_string(v))) + .collect(), + Value::Array(arr) => { + let joined = arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(","); + vec![(key.to_string(), joined)] + } + // form / object / explode=true: each property becomes its own + // top-level key (`role=admin&active=true`), dropping the parameter + // name entirely — the OpenAPI 3.0 rule for an exploded object. + Value::Object(map) if explode => map + .iter() + .map(|(k, v)| (k.clone(), value_to_query_string(v))) + .collect(), + // form / object / explode=false: comma-joined `key,value` pairs under + // the single parameter key (`profile=role,admin,active,true`). + Value::Object(map) => { + let joined = map + .iter() + .flat_map(|(k, v)| [k.clone(), value_to_query_string(v)]) + .collect::>() + .join(","); + vec![(key.to_string(), joined)] + } + _ => vec![(key.to_string(), value_to_query_string(value))], + } +} + +fn value_to_query_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Serialize a header parameter value into its OpenAPI `simple`-style wire +/// representation (the only style permitted for `in: header` parameters). +/// +/// - primitive → the scalar rendered as-is (`X-Custom: hello`) +/// - array → elements comma-joined under one name; `explode` does not change +/// the delimiter for `simple` (`X-Tags: a,b`) +/// - object, `explode: false` → flattened `k,v,k2,v2` (`X-Filter: k,v,k2,v2`) +/// - object, `explode: true` → `k=v,k2=v2` +/// +/// The fully-assembled value is rejected if it contains control characters, +/// which would otherwise enable header injection (CR/LF) when the value +/// arrives from an untrusted CLI argument. +fn serialize_header_simple( + value: &Value, + param_def: Option<&crate::openapi::discovery::MethodParameter>, +) -> Result { + let explode = param_def.and_then(|p| p.explode).unwrap_or(false); + + let rendered = match value { + Value::Array(arr) => arr + .iter() + .map(value_to_query_string) + .collect::>() + .join(","), + Value::Object(map) => map + .iter() + .flat_map(|(k, v)| { + let v = value_to_query_string(v); + if explode { + vec![format!("{k}={v}")] + } else { + vec![k.clone(), v] + } + }) + .collect::>() + .join(","), + other => value_to_query_string(other), + }; + + crate::output::reject_dangerous_chars(&rendered, "header value")?; + Ok(rendered) +} + +/// Percent-encode set for a query-string component (key or value). +/// +/// RFC 3986 unreserved characters (`A-Za-z0-9-_.~`) are left intact; the comma +/// is also kept literal so a form/no-explode array reads `ids=1,2` rather than +/// `ids=1%2C2`. Everything else — including space (`%20`, *not* the form +/// `+`), `|` (`%7C`), `&`, `=`, `#`, and `[` `]` — is percent-encoded. This is +/// the RFC 3986 encoding the OpenAPI 3.0 query styles expect, and is stricter +/// than reqwest's `serde_urlencoded`-based `.query()` form encoding. +const QUERY_COMPONENT: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~') + .remove(b','); + +fn encode_query_component(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, QUERY_COMPONENT).to_string() +} + +/// Append already-style-serialized `(key, value)` query pairs to `base_url`, +/// percent-encoding each component per [`QUERY_COMPONENT`]. Pairs are joined +/// with `&`; the leading separator is `?` unless `base_url` already carries a +/// query string, in which case `&` continues it. Returns `base_url` unchanged +/// when there are no pairs. +fn append_query_string(base_url: &str, pairs: &[(String, String)]) -> String { + if pairs.is_empty() { + return base_url.to_string(); + } + let query = pairs + .iter() + .map(|(k, v)| format!("{}={}", encode_query_component(k), encode_query_component(v))) + .collect::>() + .join("&"); + let sep = if base_url.contains('?') { '&' } else { '?' }; + format!("{base_url}{sep}{query}") +} + +fn effective_root_url(method: &RestMethod, doc: &RestDescription) -> String { + if !method.root_url.is_empty() { method.root_url.clone() } else { doc.root_url.clone() } +} + +/// Prepend `doc.base_path` (sourced from `x-fern-base-path`) to `base`, +/// inserting exactly one slash between the two segments regardless of +/// whether either side already has a slash on its boundary. Returns +/// `base` unchanged when `doc.base_path` is `None` or normalizes to +/// empty. +/// +/// Examples (server URL × base_path slash matrix): +/// - `"https://x/"` + `"/v1"` → `"https://x/v1"` +/// - `"https://x"` + `"/v1"` → `"https://x/v1"` +/// - `"https://x/"` + `"v1"` → `"https://x/v1"` +/// - `"https://x"` + `"v1"` → `"https://x/v1"` +/// - `"https://x"` + `"/v1/"` → `"https://x/v1"` (trailing slash on +/// base_path is stripped; `build_url` re-adds one before the path) +/// +/// `build_url` calls this helper uniformly across all three URL sources +/// — `--base-url` override, `doc.base_url`, and `effective_root_url + +/// service_path` — so the base path is applied *additively* on top of +/// any one of them. In particular, `--base-url https://staging/v2` on a +/// spec with `x-fern-base-path: /v1` produces `https://staging/v2/v1/...`, +/// not `https://staging/v2/...`: `x-fern-base-path` is part of the spec's +/// logical URL structure, not a property of any specific host. +/// +/// Mirrors fern-api/fern's openapi-ir-parser: +/// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernBasePath.ts`. +/// +/// The base path passed in is expected to already have any `{param}` +/// placeholders substituted — `build_url` calls `render_path_template` +/// on `doc.base_path` first so this helper only deals with the +/// post-substitution slash-edge logic. +fn apply_base_path(base: &str, base_path: Option<&str>) -> String { + let Some(bp) = base_path else { + return base.to_string(); + }; + let bp_trimmed = bp.trim_matches('/'); + if bp_trimmed.is_empty() { + return base.to_string(); + } + let base_trimmed = base.trim_end_matches('/'); + format!("{base_trimmed}/{bp_trimmed}") +} + +fn build_url( + doc: &RestDescription, + method: &RestMethod, + params: &Map, + is_upload: bool, + base_url_override: Option<&str>, +) -> Result<(String, Vec<(String, String)>), CliError> { + // Build URL base and path. The base_url here is just the server (or + // override) plus any Discovery `service_path`; x-fern-base-path is + // applied as a separate step below so the slash-edge logic stays in + // one place and applies to all three base sources (override, explicit + // `base_url`, and effective root_url + service_path). + let raw_base_url = if let Some(b) = base_url_override { + b.trim_end_matches('/').to_string() + } else if let Some(b) = &doc.base_url { + b.clone() + } else { + format!("{}{}", effective_root_url(method, doc), doc.service_path) + }; + // Render any `{param}` placeholders in `x-fern-base-path` (e.g. + // `/{tenant}/v1`) against the operation's parameters. The placeholder + // names are also collected so we can exclude them from the query + // string below — the param has been consumed by the URL path and + // must not leak as `?tenant=acme`. Mirrors upstream Fern where base + // path placeholders are baked into endpoint paths at Definition build + // time and then resolved by the SDK's path-parameter renderer at + // request time. + let rendered_base_path = doc + .base_path + .as_deref() + .map(|bp| render_path_template(bp, params, Some(&method.parameters))) + .transpose()?; + let base_path_parameters: HashSet<&str> = doc + .base_path + .as_deref() + .map(extract_template_path_parameters) + .unwrap_or_default(); + let base_url = apply_base_path(&raw_base_url, rendered_base_path.as_deref()); + + // Prefer flatPath when its placeholders match the method's path parameters. + // Some Discovery Documents (e.g., Slides presentations.get) have flatPath + // placeholders that don't match parameter names ({presentationsId} vs + // {presentationId}). In those cases, fall back to path which uses RFC 6570 + // operators ({+var}) that this function already handles. + let path_template = match method.flat_path.as_deref() { + Some(fp) => { + let all_match = method + .parameters + .iter() + .filter(|(_, p)| p.location.as_deref() == Some("path")) + .all(|(name, _)| { + let plain = format!("{{{name}}}"); + let plus = format!("{{+{name}}}"); + fp.contains(&plain) || fp.contains(&plus) + }); + if all_match { + fp + } else { + method.path.as_str() + } + } + None => method.path.as_str(), + }; + + // Substitute path parameters and separate query parameters + let path_parameters = extract_template_path_parameters(path_template); + let mut query_params: Vec<(String, String)> = Vec::new(); + + for (key, value) in params { + if path_parameters.contains(key.as_str()) { + continue; + } + // Params that backfill placeholders in `x-fern-base-path` have + // already been consumed by the URL path; they must not also + // appear as query string entries. + if base_path_parameters.contains(key.as_str()) { + continue; + } + + let is_path_param = method + .parameters + .get(key) + .and_then(|p| p.location.as_deref()) + == Some("path"); + + if is_path_param { + return Err(CliError::Validation(format!( + "Path parameter '{key}' was provided but is not present in URL template '{path_template}'" + ))); + } + + // Use style-aware serialization for query parameters. + // For backward compatibility, `repeated` params still use the legacy + // expansion (equivalent to form+explode). + let param_def = method.parameters.get(key); + let is_repeated = param_def.map(|p| p.repeated).unwrap_or(false); + + if is_repeated { + if let Value::Array(arr) = value { + for item in arr { + let val_str = match item { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + query_params.push((key.clone(), val_str)); + } + continue; + } + } + + let pairs = serialize_query_param(key, value, param_def); + query_params.extend(pairs); + } + + let url_path = render_path_template(path_template, params, Some(&method.parameters))?; + + let full_url = if is_upload { + // Use the upload endpoint from the Discovery Document + let upload_endpoint = method + .media_upload + .as_ref() + .and_then(|mu| mu.protocols.as_ref()) + .and_then(|p| p.simple.as_ref()) + .map(|s| s.path.as_str()) + .ok_or_else(|| { + CliError::Validation( + "Method supports media upload but no upload path found in Discovery Document" + .to_string(), + ) + })?; + let upload_path = render_path_template(upload_endpoint, params, Some(&method.parameters))?; + // Compose the upload host with the spec-level base_path the same + // way the non-upload branch does, so x-fern-base-path is applied + // uniformly. This branch is currently unreachable from OpenAPI + // specs (only Google Discovery sets `media_upload`, and Discovery + // specs don't carry `base_path`), but keeping the wiring + // symmetric prevents a silent gap if either side ever changes. + let root = base_url_override + .map(|b| b.trim_end_matches('/').to_string()) + .unwrap_or_else(|| effective_root_url(method, doc).trim_end_matches('/').to_string()); + let root = apply_base_path(&root, rendered_base_path.as_deref()); + format!("{root}{upload_path}") + } else { + match (base_url.ends_with('/'), url_path.starts_with('/')) { + (true, true) => format!("{}{}", base_url.trim_end_matches('/'), url_path), + (false, false) => format!("{base_url}/{url_path}"), + _ => format!("{base_url}{url_path}"), + } + }; + + Ok((full_url, query_params)) +} + +fn extract_template_path_parameters(path_template: &str) -> HashSet<&str> { + let mut found = HashSet::new(); + let mut cursor = 0; + + while let Some(open_idx) = path_template[cursor..].find('{') { + let token_start = cursor + open_idx; + let Some(close_idx) = path_template[token_start..].find('}') else { + break; + }; + + let token_end = token_start + close_idx; + let token = &path_template[token_start + 1..token_end]; + if let Some(key) = token.strip_prefix('+') { + found.insert(key); + } else { + found.insert(token); + } + cursor = token_end + 1; + } + + found +} + +fn render_path_template( + path_template: &str, + params: &Map, + param_defs: Option<&HashMap>, +) -> Result { + let mut rendered = String::with_capacity(path_template.len()); + let mut cursor = 0; + + while let Some(open_idx) = path_template[cursor..].find('{') { + let token_start = cursor + open_idx; + rendered.push_str(&path_template[cursor..token_start]); + + let Some(close_idx) = path_template[token_start..].find('}') else { + rendered.push_str(&path_template[token_start..]); + return Ok(rendered); + }; + + let token_end = token_start + close_idx; + let token = &path_template[token_start + 1..token_end]; + let (is_plus, key) = if let Some(key) = token.strip_prefix('+') { + (true, key) + } else { + (false, token) + }; + + if let Some(value) = params.get(key) { + let encoded = if is_plus { + // RFC 6570 `{+var}` reserved expansion: preserve literal `/`. + let val_str = match value { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + let validated = crate::validate::validate_resource_name(&val_str)?; + crate::validate::encode_path_preserving_slashes(validated) + } else { + // Consult the parameter's OpenAPI serialization `style` + // (simple / label / matrix) and `explode` flag. Falls back + // to plain simple/primitive substitution when no definition + // is available (e.g. `x-fern-base-path` placeholders). + let param_def = param_defs.and_then(|defs| defs.get(key)); + let serialized = serialize_path_param(key, value, param_def); + // Reject WHATWG dot-segments: the `url` crate normalizes + // `.` and `..` (and their percent-encoded forms) during + // Url::parse(), silently redirecting the request. Encoding + // can't prevent this — rejection is the only defense. + if crate::validate::is_dot_segment(&serialized) { + return Err(CliError::Validation(format!( + "Path parameter '{key}' produced a WHATWG dot-segment ('{serialized}') \ + that would be collapsed during URL normalization" + ))); + } + serialized + }; + rendered.push_str(&encoded); + } else { + rendered.push_str(&path_template[token_start..=token_end]); + } + + cursor = token_end + 1; + } + + rendered.push_str(&path_template[cursor..]); + Ok(rendered) +} + +/// Serialize a value into a single URL path segment per the OpenAPI 3.0 path +/// `style` (`simple` default, `label`, `matrix`) and `explode` flag. +/// +/// Only the user-supplied *values* are percent-encoded +/// ([`encode_path_segment`](crate::validate::encode_path_segment)); the +/// structural separators introduced by the style (`,`, `.`, `;`, `=`) are +/// literal. Because `encode_path_segment` itself encodes those characters, +/// assembling the segment from already-encoded values keeps the separators +/// from being double-encoded. +fn serialize_path_param( + name: &str, + value: &Value, + param_def: Option<&MethodParameter>, +) -> String { + let style = param_def + .and_then(|p| p.style.as_deref()) + .unwrap_or("simple"); + // OpenAPI default `explode` is false for every path style. + let explode = param_def.and_then(|p| p.explode).unwrap_or(false); + + let enc = |v: &Value| crate::validate::encode_path_segment(&value_to_path_string(v)); + + match style { + "label" => match value { + Value::Array(arr) => { + // RFC 6570: explode=true -> dot-separated; explode=false -> comma-separated. + let joiner = if explode { "." } else { "," }; + let body = arr.iter().map(&enc).collect::>().join(joiner); + format!(".{body}") + } + Value::Object(map) => { + if explode { + // explode=true: k=v pairs dot-separated. + let body = map + .iter() + .map(|(k, v)| { + format!("{}={}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join("."); + format!(".{body}") + } else { + // explode=false: flat k,v,k,v comma-separated. + let body = map + .iter() + .flat_map(|(k, v)| { + [crate::validate::encode_path_segment(k), enc(v)] + }) + .collect::>() + .join(","); + format!(".{body}") + } + } + _ => format!(".{}", enc(value)), + }, + "matrix" => match value { + Value::Array(arr) if explode => arr + .iter() + .map(|v| format!(";{name}={}", enc(v))) + .collect::>() + .join(""), + Value::Array(arr) => { + let body = arr.iter().map(&enc).collect::>().join(","); + format!(";{name}={body}") + } + Value::Object(map) if explode => map + .iter() + .map(|(k, v)| { + format!(";{}={}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join(""), + Value::Object(map) => { + let body = map + .iter() + .map(|(k, v)| { + format!("{},{}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join(","); + format!(";{name}={body}") + } + _ => format!(";{name}={}", enc(value)), + }, + // "simple" (default) and any unrecognized style. + _ => match value { + Value::Array(arr) => arr.iter().map(&enc).collect::>().join(","), + Value::Object(map) if explode => map + .iter() + .map(|(k, v)| { + format!("{}={}", crate::validate::encode_path_segment(k), enc(v)) + }) + .collect::>() + .join(","), + Value::Object(map) => map + .iter() + .flat_map(|(k, v)| [crate::validate::encode_path_segment(k), enc(v)]) + .collect::>() + .join(","), + _ => enc(value), + }, + } +} + +/// Stringify a JSON value for a path segment. Mirrors `value_to_query_string` +/// (the query-side equivalent) — strings pass through, numbers/booleans use +/// their canonical text, null is empty, composites fall back to JSON. +fn value_to_path_string(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => String::new(), + other => other.to_string(), + } +} + +/// Resolves the MIME type for the uploaded media content. +/// +/// Priority: +/// 1. `--upload-content-type` flag (explicit override) +/// 2. File extension inference (common extensions mapped to MIME types) +/// 3. Metadata `mimeType` (fallback for backward compatibility) +/// 4. `application/octet-stream` +/// +/// All returned MIME types have control characters stripped to prevent +/// MIME header injection via user-controlled metadata. +fn resolve_upload_mime( + explicit: Option<&str>, + upload_path: Option<&str>, + metadata: &Option, +) -> String { + let raw = explicit + .map(|s| s.to_string()) + .or_else(|| upload_path.and_then(mime_from_extension)) + .or_else(|| { + metadata + .as_ref() + .and_then(|m| m.get("mimeType")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + // Strip CR/LF and other control characters to prevent MIME header injection. + sanitize_mime(raw) +} + +/// Simple MIME type inference from file extension. +/// Returns `None` for unrecognized extensions. +fn mime_from_extension(path: &str) -> Option { + let ext = path.rsplit('.').next()?.to_lowercase(); + let mime = match ext.as_str() { + "txt" => "text/plain", + "html" | "htm" => "text/html", + "css" => "text/css", + "csv" => "text/csv", + "xml" => "application/xml", + "json" => "application/json", + "js" => "application/javascript", + "pdf" => "application/pdf", + "zip" => "application/zip", + "gz" | "gzip" => "application/gzip", + "tar" => "application/x-tar", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "ico" => "image/x-icon", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + // Speech-to-text and dubbing endpoints take these routinely, and a + // server that validates the part's media type rejects an upload + // labelled `application/octet-stream`. + "m4a" => "audio/mp4", + "aac" => "audio/aac", + "flac" => "audio/flac", + "ogg" | "oga" => "audio/ogg", + "opus" => "audio/opus", + "aif" | "aiff" => "audio/aiff", + "mp4" => "video/mp4", + "webm" => "video/webm", + "mov" => "video/quicktime", + // Accepted by document-ingestion endpoints (e.g. knowledge bases). + "epub" => "application/epub+zip", + "md" | "markdown" => "text/markdown", + "yaml" | "yml" => "application/yaml", + "toml" => "application/toml", + "doc" => "application/msword", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xls" => "application/vnd.ms-excel", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "ppt" => "application/vnd.ms-powerpoint", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "wasm" => "application/wasm", + _ => return None, + }; + Some(mime.to_string()) +} + +/// Streams stdin as a raw request body via chunked transfer encoding. +/// Used when the user passes `-` to the binary-body flag. +fn build_stdin_body_stream() -> reqwest::Body { + let stream = tokio_util::io::ReaderStream::new(tokio::io::stdin()); + reqwest::Body::wrap_stream(stream) +} + +/// Streams a file as a raw request body. Used for operations whose request +/// body is declared as a binary content type (e.g. `application/octet-stream`). +/// Memory usage stays at O(64 KB) regardless of file size. +/// +/// `flag_name` is the spec-derived CLI flag (`file`, `body`, or whatever +/// `x-fern-parameter-name` set) — surfaced in the error message if the file +/// disappears between the upfront `metadata()` check and stream open (TOCTOU). +fn build_binary_file_stream( + file_path: &str, + file_size: u64, + flag_name: &str, +) -> (reqwest::Body, u64) { + let file_path_owned = file_path.to_owned(); + let flag_owned = flag_name.to_owned(); + let stream = futures_util::stream::once(async move { + tokio::fs::File::open(&file_path_owned).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to open --{flag_owned} '{file_path_owned}': {e}"), + ) + }) + }) + .map_ok(tokio_util::io::ReaderStream::new) + .try_flatten(); + + (reqwest::Body::wrap_stream(stream), file_size) +} + +/// Builds a streaming multipart/related body for media upload requests. +/// +/// Instead of reading the entire file into memory, this streams the file in +/// chunks via `ReaderStream`, keeping memory usage at O(64 KB) regardless of +/// file size. The `Content-Length` is pre-computed from file metadata so APIs +/// Generate a unique boundary ID for multipart requests using timestamp. +fn generate_boundary_id() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64 +} + +/// still receive the correct header without buffering. +/// +/// Returns `(body, content_type, content_length)`. +fn build_multipart_stream( + metadata: &Option, + file_path: &str, + file_size: u64, + media_mime: &str, +) -> Result<(reqwest::Body, String, u64), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + let media_mime = media_mime.to_string(); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + CliError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let content_length = preamble.len() as u64 + file_size + postamble.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + let preamble_bytes: bytes::Bytes = preamble.into_bytes().into(); + let postamble_bytes: bytes::Bytes = postamble.into_bytes().into(); + + let file_path_owned = file_path.to_owned(); + let file_stream = futures_util::stream::once(async move { + tokio::fs::File::open(&file_path_owned).await.map_err(|e| { + std::io::Error::new( + e.kind(), + format!("failed to open upload file '{file_path_owned}': {e}"), + ) + }) + }) + .map_ok(tokio_util::io::ReaderStream::new) + .try_flatten(); + + let stream = futures_util::stream::once(async { Ok::<_, std::io::Error>(preamble_bytes) }) + .chain(file_stream) + .chain(futures_util::stream::once(async { + Ok::<_, std::io::Error>(postamble_bytes) + })); + + Ok(( + reqwest::Body::wrap_stream(stream), + content_type, + content_length, + )) +} + +/// Header names the spec itself declares as credentials — the `name` of every +/// `apiKey`-in-header security scheme (`xi-api-key`, `X-API-Key`, …). +/// +/// Shared by `--debug` and `--dry-run` so a header redacted in one is redacted +/// in the other. `debug::REDACTED_HEADERS` covers the well-known names; this +/// covers the ones only the spec knows about. +pub(crate) fn spec_sensitive_header_names(doc: &RestDescription) -> Vec<&str> { + doc.security_schemes + .values() + .filter_map(|s| { + if let crate::openapi::discovery::SecurityScheme::ApiKeyHeader { name } = s { + Some(name.as_str()) + } else { + None + } + }) + .collect() +} + +/// Resolve a file part's `Content-Type`: +/// +/// 1. A per-part value from the OpenAPI `encoding` object (explicit wins) +/// 2. Inference from the file's extension +/// 3. `application/octet-stream` +/// +/// Same precedence [`resolve_upload_mime`] already applies to binary request +/// bodies — this path previously skipped step 2 and labelled *every* upload +/// `application/octet-stream`. That is the OAS default for a binary part and so +/// technically conformant, but servers that validate a part's media type reject +/// it outright: ElevenLabs' knowledge-base upload, for one, answers +/// `Invalid file type. Allowed types are ['application/pdf', 'text/plain', …]` +/// for a `.txt` file the CLI mislabelled. Since most specs omit `encoding` +/// entirely, that made uploads impossible against any strict server. +/// +/// `file_name` is `None` when the payload is not the file's native bytes — +/// stdin, or an `@text`/`@data` transform that rewrites the content (a base64 +/// re-encoding of a PNG is not `image/png`). Those keep the octet-stream +/// default rather than claiming a type the bytes no longer have. +fn file_part_mime(content_type: Option<&str>, file_name: Option<&str>) -> String { + let raw = content_type + .map(|s| s.to_string()) + .or_else(|| file_name.and_then(mime_from_extension)) + .unwrap_or_else(|| "application/octet-stream".to_string()); + sanitize_mime(raw) +} + +/// Strip control characters from a resolved MIME type to prevent header +/// injection via user-controlled paths or spec metadata, falling back to +/// `application/octet-stream` if nothing survives. +fn sanitize_mime(raw: String) -> String { + let sanitized: String = raw.chars().filter(|c| !c.is_control()).collect(); + if sanitized.is_empty() { + "application/octet-stream".to_string() + } else { + sanitized + } +} + +/// Build a `reqwest::multipart::Form` from the collected CLI flag values. +/// Text parts are added inline; file parts are read from disk and +/// streamed. The `Content-Type: multipart/form-data; boundary=...` +/// header is set by reqwest automatically when `.multipart(form)` is +/// called on the request builder. +async fn build_multipart_form( + parts: &[MultipartPart], +) -> Result { + let mut form = reqwest::multipart::Form::new(); + + for part in parts { + match part { + MultipartPart::Text { + name, + value, + content_type, + } => { + // A text part is just `Part::text`; an explicit per-part + // `Content-Type` from the OpenAPI `encoding` object (e.g. + // `application/json`) overrides reqwest's `text/plain`. + let mut text_part = reqwest::multipart::Part::text(value.clone()); + if let Some(ct) = content_type { + text_part = text_part.mime_str(ct).map_err(|e| { + CliError::Validation(format!( + "Invalid Content-Type '{ct}' for multipart field '{name}': {e}" + )) + })?; + } + form = form.part(name.clone(), text_part); + } + MultipartPart::File { + name, + path, + content_type, + } => { + // `\@literal` is an escape, not a file path — send the literal + // string `@literal` as the part value (no file read). FER-10436. + // `collect_multipart_parts` normally routes the escape to a + // Text part upstream; this branch is the defensive fallback + // for any caller that constructs a File part directly. + if is_escaped_literal(path) { + let literal = strip_or_escape_at(path).into_owned(); + let mut literal_part = reqwest::multipart::Part::text(literal); + if let Some(ct) = content_type { + literal_part = literal_part.mime_str(ct).map_err(|e| { + CliError::Validation(format!( + "Invalid Content-Type '{ct}' for multipart field '{name}': {e}" + )) + })?; + } + form = form.part(name.clone(), literal_part); + continue; + } + // Parse the raw stored path so we know which encoding the + // user asked for (`Auto` → raw bytes, `Text` → UTF-8 only, + // `Data` → always base64 — FER-10532). Stdin (`@-` / `-`) + // is only reachable through `Auto`. + let (inner_path, mode) = match parse_at_ref(path) { + AtRef::File { path: p, mode } => (p, mode), + AtRef::Plain(s) => (Cow::Borrowed(s), AtMode::Auto), + // `\@literal` was handled above; reachable only for a + // defensively-constructed File part. Fall back to the + // FER-10436 contract: open a file whose name is the + // literal `@`. + AtRef::Escaped(literal) => (Cow::Owned(literal), AtMode::Auto), + }; + let inner_ref: &str = inner_path.as_ref(); + let is_stdin = mode == AtMode::Auto && inner_ref == "-"; + let (bytes, file_name) = if is_stdin { + let mut buf = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut tokio::io::stdin(), &mut buf) + .await + .map_err(|e| { + CliError::Validation(format!( + "Failed to read stdin for multipart field '{name}': {e}" + )) + })?; + (buf, "stdin".to_string()) + } else { + let file_bytes = tokio::fs::read(inner_ref).await.map_err(|e| { + CliError::Validation(format!( + "Failed to read file '{inner_ref}' for multipart field '{name}': {e}" + )) + })?; + let file_name = std::path::Path::new(inner_ref) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("upload") + .to_string(); + (file_bytes, file_name) + }; + // Apply the FER-10532 mode transform when the user asked + // for one. `Auto` and stdin keep their raw-byte path. + let part_bytes: Vec = match mode { + AtMode::Auto => bytes, + AtMode::Text | AtMode::Data => encode_file_bytes_for_text( + bytes, + mode, + &format!("multipart field '{name}'"), + inner_ref, + )? + .into_bytes(), + }; + // Resolved here, not before the read: inferring the media type + // from the extension needs the resolved file name, and only the + // untransformed `Auto` path still carries the file's own bytes. + let mime = file_part_mime( + content_type.as_deref(), + if mode == AtMode::Auto && !is_stdin { + Some(file_name.as_str()) + } else { + None + }, + ); + let file_part = reqwest::multipart::Part::bytes(part_bytes) + .file_name(file_name) + .mime_str(&mime) + .map_err(|e| { + CliError::Validation(format!( + "Invalid Content-Type '{mime}' for multipart field '{name}': {e}" + )) + })?; + form = form.part(name.clone(), file_part); + } + } + } + + Ok(form) +} + +/// Builds a multipart/related body from in-memory bytes. +/// +/// Used when the upload content is constructed in memory (e.g., a Gmail RFC 5322 +/// message with attachments) rather than read from a file on disk. +fn build_multipart_bytes( + metadata: &Option, + data: &[u8], + media_mime: &str, +) -> Result<(reqwest::Body, String, u64), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + let metadata_json = match metadata { + Some(m) => serde_json::to_string(m).map_err(|e| { + CliError::Validation(format!("Failed to serialize upload metadata: {e}")) + })?, + None => "{}".to_string(), + }; + + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: {media_mime}\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + + let mut body = Vec::with_capacity(preamble.len() + data.len() + postamble.len()); + body.extend_from_slice(preamble.as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(postamble.as_bytes()); + + let content_length = body.len() as u64; + let content_type = format!("multipart/related; boundary={boundary}"); + + Ok((reqwest::Body::from(body), content_type, content_length)) +} + +/// Builds a buffered multipart/related body for media upload requests. +/// +/// This is the legacy implementation retained for unit tests that need +/// a fully materialized body to assert against. +/// +/// Returns the body bytes and the Content-Type header value (with boundary). +#[cfg(test)] +fn build_multipart_body( + metadata: &Option, + file_bytes: &[u8], + media_mime: &str, +) -> Result<(Vec, String), CliError> { + let boundary = format!("fern_boundary_{:016x}", generate_boundary_id()); + + // Build multipart/related body + let metadata_json = metadata + .as_ref() + .map(|m| serde_json::to_string(m).unwrap_or_else(|_| "{}".to_string())) + .unwrap_or_else(|| "{}".to_string()); + + let mut body = Vec::new(); + // Part 1: JSON metadata + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n"); + body.extend_from_slice(metadata_json.as_bytes()); + body.extend_from_slice(b"\r\n"); + // Part 2: File content + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(format!("Content-Type: {media_mime}\r\n\r\n").as_bytes()); + body.extend_from_slice(file_bytes); + body.extend_from_slice(b"\r\n"); + // Closing boundary + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + + let content_type = format!("multipart/related; boundary={boundary}"); + Ok((body, content_type)) +} + +/// Merge body-location global parameters into an assembled body value. +/// Handles both the `--json` and per-field-flags body paths. Non-object +/// bodies (arrays, scalars) are left untouched since we can't inject +/// named fields into them. When the body is `None`, a new object is +/// created to carry the global fields. +fn merge_global_body_params( + body: Option, + extra_global_params: &[crate::openapi::app::ResolvedGlobalParam], +) -> Option { + use crate::openapi::discovery::GlobalParameterLocation; + + let has_body_globals = extra_global_params + .iter() + .any(|gp| gp.location == GlobalParameterLocation::Body); + if !has_body_globals { + return body; + } + + match body { + Some(Value::Object(mut m)) => { + for gp in extra_global_params + .iter() + .filter(|gp| gp.location == GlobalParameterLocation::Body) + { + // Per-op wins: only inject where the user hasn't already + // supplied a value at the (possibly nested) target path. + set_nested_value_if_absent(&mut m, &gp.target, Value::String(gp.value.clone())); + } + Some(Value::Object(m)) + } + Some(other) => { + // Non-object body — can't inject named fields; leave as-is. + Some(other) + } + None => { + let mut m = Map::new(); + for gp in extra_global_params + .iter() + .filter(|gp| gp.location == GlobalParameterLocation::Body) + { + set_nested_value_if_absent(&mut m, &gp.target, Value::String(gp.value.clone())); + } + Some(Value::Object(m)) + } + } +} + +/// Intentional duplication from `graphql/executor.rs` — no shared module by design. +fn set_nested_value(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.insert(path.to_string(), value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(nested_map) = nested { + set_nested_value(nested_map, tail, value); + } + } + } +} + +/// Like [`set_nested_value`] but never overwrites a value the user has +/// already supplied: it no-ops on an existing leaf and never replaces a +/// non-object node encountered while walking a dotted path. This is what +/// enforces "per-op wins" for body-location global parameters — a flat +/// `contains_key(target)` check can't see a nested target like +/// `config.currency` (the assembled body has no top-level key literally +/// named `"config.currency"`), so the presence test must walk the path. +fn set_nested_value_if_absent(obj: &mut Map, path: &str, value: Value) { + match path.split_once('.') { + None => { + obj.entry(path.to_string()).or_insert(value); + } + Some((head, tail)) => { + let nested = obj + .entry(head.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + // If the user already put a non-object here, leave it untouched. + if let Value::Object(nested_map) = nested { + set_nested_value_if_absent(nested_map, tail, value); + } + } + } +} + +/// Apply the appropriate body encoding to the request based on the +/// [`BodyEncoding`] variant. Sets the `Content-Type` header and body payload. +fn encode_request_body( + request: reqwest::RequestBuilder, + body: &Value, + encoding: &BodyEncoding, +) -> reqwest::RequestBuilder { + match encoding { + BodyEncoding::Json => request + .header("Content-Type", encoding.content_type()) + .json(body), + BodyEncoding::FormUrlEncoded => { + let encoded = encode_form_body(body); + request + .header("Content-Type", encoding.content_type()) + .body(encoded) + } + } +} + +/// Encode a JSON `Value` (expected to be an Object) into a +/// `application/x-www-form-urlencoded` string. Top-level keys are +/// emitted as-is; arrays repeat the key (e.g. `tag=a&tag=b`). +/// Nested objects and arrays-of-objects are JSON-encoded as the value +/// — no dot-notation or bracket expansion — so the encoding stays +/// predictable for servers that treat `.` as a literal character. +/// Non-object top-level values are serialized as a single +/// `body=` pair. +fn encode_form_body(val: &Value) -> String { + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Value::Object(map) = val { + collect_form_pairs(map, &mut pairs); + } else { + pairs.push(("body".to_string(), value_to_form_str(val))); + } + form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs) + .finish() +} + +fn collect_form_pairs(map: &Map, out: &mut Vec<(String, String)>) { + for (key, value) in map { + match value { + Value::Array(items) => { + for item in items { + out.push((key.clone(), value_to_form_str(item))); + } + } + _ => out.push((key.clone(), value_to_form_str(value))), + } + } +} + +fn value_to_form_str(val: &Value) -> String { + match val { + Value::String(s) => s.clone(), + Value::Null => String::new(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + other => other.to_string(), + } +} + +/// +/// CLI flags arrive as `Value::String` (clap stores them as `String`), but a +/// body field declared `integer` / `number` / `boolean` should land in the +/// JSON body with the right runtime type, not as a quoted string. Values +/// supplied via `--params` are already typed by `serde_json` and pass through +/// unchanged. `object` and `array` types are JSON-decoded so callers can pass +/// nested structures via individual flags (e.g. `--addresses '[{"city":"SF"}]'`). +fn coerce_body_param_value(value: &Value, param_type: Option<&str>) -> Result { + // For object-shorthand body flags, validate shape regardless of whether + // the value arrives here as a raw String (legacy / direct unit-test entry) + // or as a pre-decoded Value. `collect_params_from_flags` eagerly + // JSON-decodes object-typed params for deepObject query handling, so + // production calls usually arrive pre-decoded — but the decoded form may + // itself be a Value::String (the JSON `"hi"` decodes to one), and the + // fallback path leaves Value::String unchanged for un-decodable input. + // Try a re-decode on String inputs; if that fails, treat the value as-is. + // Either way the final shape must be a JSON object. + if param_type == Some("object") { + let parsed = if let Value::String(raw) = value { + serde_json::from_str::(raw).unwrap_or_else(|_| value.clone()) + } else { + value.clone() + }; + if !parsed.is_object() { + return Err(CliError::Validation(format!( + "Object-shorthand flag must be a JSON object, got {}", + match &parsed { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => unreachable!(), + } + ))); + } + return Ok(parsed); + } + + let Value::String(raw) = value else { + return Ok(value.clone()); + }; + match param_type { + Some("integer") => raw + .parse::() + .map(|n| Value::Number(n.into())) + .map_err(|e| CliError::Validation(format!("Invalid integer body value '{raw}': {e}"))), + Some("number") => { + let n = raw.parse::().map_err(|e| { + CliError::Validation(format!("Invalid number body value '{raw}': {e}")) + })?; + serde_json::Number::from_f64(n) + .map(Value::Number) + .ok_or_else(|| CliError::Validation(format!("Non-finite number body value '{raw}'"))) + } + Some("boolean") => match raw.as_str() { + "true" | "1" => Ok(Value::Bool(true)), + "false" | "0" => Ok(Value::Bool(false)), + _ => Err(CliError::Validation(format!( + "Invalid boolean body value '{raw}' (expected true/false)" + ))), + }, + Some("array") => serde_json::from_str(raw).map_err(|e| { + CliError::Validation(format!("Invalid JSON body value for nested field: {e}")) + }), + _ => Ok(Value::String(raw.clone())), + } +} + +/// Validates a JSON body against a Discovery Document schema. +fn validate_body_against_schema( + body: &Value, + schema_name: &str, + doc: &RestDescription, +) -> Result<(), CliError> { + let mut errors = Vec::new(); + validate_value(body, schema_name, doc, "$", &mut errors); + + if !errors.is_empty() { + return Err(CliError::Validation(format!( + "Request body failed schema validation:\n- {}", + errors.join("\n- ") + ))); + } + + Ok(()) +} + +fn validate_value( + value: &Value, + schema_ref_name: &str, + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + let schema = match doc.schemas.get(schema_ref_name) { + Some(s) => s, + None => { + errors.push(format!("{path}: Schema '{schema_ref_name}' not found")); + return; + } + }; + + // Null on a nullable schema is always valid — mirrors the property- + // level null short-circuit in `validate_property`. Without this, a + // `$ref`-resolved schema that is `nullable: true` or contains a + // nullable-union composition (`oneOf: [T, null]` / `anyOf: [T, null]`) + // would incorrectly reject JSON null with "Expected object". + if value.is_null() + && (schema.nullable + || has_null_branch(&schema.one_of) + || has_null_branch(&schema.any_of)) + { + return; + } + + // Enter the object branch on a standard object schema *or* an + // `allOf`-only root (no `type:` declared but composition branches + // contribute the property set). See ADR-0004. + let has_all_of = !schema.all_of.is_empty(); + if schema.schema_type.as_deref() == Some("object") + || !schema.properties.is_empty() + || has_all_of + { + if let Value::Object(obj) = value { + if has_all_of { + let (merged_props, merged_required) = merge_top_level_all_of(schema, doc); + validate_properties(obj, &merged_props, &merged_required, doc, path, errors); + } else { + validate_properties(obj, &schema.properties, &schema.required, doc, path, errors); + } + } else { + errors.push(format!("{path}: Expected object")); + } + } +} + +/// Mirror of `parser::merge_all_of_properties` for the validator's IR +/// layer (`JsonSchema` + `JsonSchemaProperty` instead of +/// `OpenApiSchemaObject`). Walks `allOf` branches, resolving `$ref`s +/// through `doc.schemas`, and returns the merged property map + sorted +/// union of `required:` arrays. The schema's own properties are the +/// final overlay (last-branch-wins per ADR-0004). The returned `Vec` is +/// sorted so 'Missing required property X' diagnostics surface in +/// stable order across runs. +fn merge_top_level_all_of( + schema: &crate::openapi::discovery::JsonSchema, + doc: &RestDescription, +) -> ( + HashMap, + Vec, +) { + let mut props: HashMap = HashMap::new(); + let mut required: std::collections::HashSet = std::collections::HashSet::new(); + for branch in &schema.all_of { + walk_all_of_for_validate(branch, doc, &mut props, &mut required, 0); + } + for (k, v) in &schema.properties { + props.insert(k.clone(), v.clone()); + } + for r in &schema.required { + required.insert(r.clone()); + } + let mut required_vec: Vec = required.into_iter().collect(); + required_vec.sort(); + (props, required_vec) +} + +/// Same merge, but for a nested `JsonSchemaProperty` that has +/// `prop_type == "object"` with an `allOf` overlay. Returns the merged +/// property map plus the sorted union of `required:` arrays contributed +/// by `$ref`-resolved branches. +/// +/// Inline `JsonSchemaProperty` branches cannot contribute `required` +/// (the IR doesn't carry it at this layer — ADR-0004 known gap). Only +/// `$ref`-resolved branches, which `walk_all_of_for_validate` looks up +/// as `JsonSchema`s, surface their `required:` arrays here. +fn merge_property_all_of( + prop: &crate::openapi::discovery::JsonSchemaProperty, + doc: &RestDescription, +) -> ( + HashMap, + Vec, +) { + let mut props: HashMap = HashMap::new(); + let mut required: std::collections::HashSet = std::collections::HashSet::new(); + for branch in &prop.all_of { + walk_all_of_for_validate(branch, doc, &mut props, &mut required, 0); + } + for (k, v) in &prop.properties { + props.insert(k.clone(), v.clone()); + } + let mut required_vec: Vec = required.into_iter().collect(); + required_vec.sort(); + (props, required_vec) +} + +/// Recursion helper for both `merge_top_level_all_of` and +/// `merge_property_all_of`. `branch` is a `JsonSchemaProperty`; when its +/// `$ref` is set, the helper resolves through `doc.schemas` (which holds +/// `JsonSchema`s — the only IR layer that carries `required`). +fn walk_all_of_for_validate( + branch: &crate::openapi::discovery::JsonSchemaProperty, + doc: &RestDescription, + props: &mut HashMap, + required: &mut std::collections::HashSet, + depth: u8, +) { + // Match `parser::MAX_ALL_OF_DEPTH` — see ADR-0004 § Depth budget for + // the rationale. Inlined as a constant because the parser-side value + // is private and AGENTS.md forbids shared abstractions between paths. + const MAX_ALL_OF_DEPTH_VALIDATOR: u8 = 8; + if depth >= MAX_ALL_OF_DEPTH_VALIDATOR { + // Match the parser-side warning so cyclic $ref chains surface + // uniformly whether they're hit at parse time or at body + // validation time. + tracing::warn!( + "allOf recursion exceeded {MAX_ALL_OF_DEPTH_VALIDATOR} levels; truncating. Likely a cyclic $ref chain." + ); + return; + } + if let Some(ref_name) = &branch.schema_ref { + if let Some(referenced) = doc.schemas.get(ref_name) { + for inner in &referenced.all_of { + walk_all_of_for_validate(inner, doc, props, required, depth + 1); + } + for (k, v) in &referenced.properties { + props.insert(k.clone(), v.clone()); + } + for r in &referenced.required { + required.insert(r.clone()); + } + } else { + tracing::warn!("allOf branch references unresolvable schema: {ref_name}"); + } + return; + } + for inner in &branch.all_of { + walk_all_of_for_validate(inner, doc, props, required, depth + 1); + } + for (k, v) in &branch.properties { + props.insert(k.clone(), v.clone()); + } + // Inline `JsonSchemaProperty` branches have no `required` (the IR + // doesn't carry it at this layer). Branch-level required from inline + // composition is a known gap; see ADR-0004 § Consequences. +} + +/// True when any composition branch is a null sentinel — the validator's +/// null short-circuit for ADR-0005's promoted nullable unions. Mirrors +/// the three forms the parser's `is_null_sentinel` recognizes, after +/// lowering to `JsonSchemaProperty`: +/// +/// | Spec form | Lowered shape | +/// |---|---| +/// | `{type: 'null'}` (3.1 scalar) | `prop_type: Some("null")` | +/// | `{type: ['null']}` (3.1 array) | `prop_type: None, nullable: true` | +/// | `{nullable: true}` standalone (3.0 idiom) | `prop_type: None, nullable: true` | +/// +/// Without the second clause, the validator misses the 3.0 / 3.1-array +/// forms and would reject JSON null on a property the parser correctly +/// promoted — a parser/validator asymmetry caught by Devin's review. +fn has_null_branch(branches: &[crate::openapi::discovery::JsonSchemaProperty]) -> bool { + branches + .iter() + .any(|b| b.prop_type.as_deref() == Some("null") || (b.nullable && b.prop_type.is_none())) +} + +fn validate_properties( + obj: &Map, + properties: &HashMap, + required_keys: &[String], + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + // Check required keys first + for req_key in required_keys { + if !obj.contains_key(req_key) { + errors.push(format!("{path}: Missing required property '{req_key}'")); + } + } + + // An empty properties map means "any additional properties are allowed" + // (JSON Schema default when additionalProperties is not explicitly false). + if properties.is_empty() { + return; + } + + let valid_keys: std::collections::HashSet<&String> = properties.keys().collect(); + + for (key, val) in obj { + let current_path = if path == "$" { + key.clone() + } else { + format!("{path}.{key}") + }; + + if !valid_keys.contains(key) { + errors.push(format!( + "{current_path}: Unknown property. Valid properties: {:?}", + valid_keys.iter().map(|k| k.as_str()).collect::>() + )); + continue; + } + + let prop_schema = &properties[key]; + validate_property(val, prop_schema, doc, ¤t_path, errors); + } +} + +fn validate_property( + value: &Value, + prop_schema: &crate::openapi::discovery::JsonSchemaProperty, + doc: &RestDescription, + path: &str, + errors: &mut Vec, +) { + // 1. Resolve $ref if present + if let Some(ref_name) = &prop_schema.schema_ref { + validate_value(value, ref_name, doc, path, errors); + return; + } + + // Null on a nullable property is always valid — short-circuits type + // checking that would otherwise reject `null` for a `string` / + // `integer` / etc. base type. Also honors ADR-0005's nullable-union + // promotion: a property whose composition has a `{type: 'null'}` + // branch accepts null even when the intrinsic `nullable` flag is + // false (which it is for `anyOf: [scalar, null]` shapes, since the + // null-ness lives in the branch, not on the parent schema). + if value.is_null() + && (prop_schema.nullable + || has_null_branch(&prop_schema.one_of) + || has_null_branch(&prop_schema.any_of)) + { + return; + } + + // 2. Type checking + if let Some(expected_type) = &prop_schema.prop_type { + let type_matches = match (expected_type.as_str(), value) { + ("string", Value::String(_)) => true, + ("integer", Value::Number(n)) => n.is_i64() || n.is_u64(), + ("number", Value::Number(_)) => true, + ("boolean", Value::Bool(_)) => true, + ("array", Value::Array(_)) => true, + ("object", Value::Object(_)) => true, + ("any", _) => true, + _ => false, + }; + + if !type_matches { + errors.push(format!( + "{path}: Expected type '{expected_type}', found {}", + get_value_type(value) + )); + return; // Stop further validation for this property if the type is wrong + } + } + + // 3. Array items validation + if prop_schema.prop_type.as_deref() == Some("array") { + if let Some(items_schema) = &prop_schema.items { + if let Value::Array(arr) = value { + for (i, item) in arr.iter().enumerate() { + let item_path = format!("{path}[{i}]"); + validate_property(item, items_schema, doc, &item_path, errors); + } + } + } + } + + // 4. Object properties validation. Enters on a standard object + // schema *or* an object property with an `allOf:` overlay + // contributing its property set (ADR-0004). Without the `has_all_of` + // clause on the *outer* condition, a property declared as bare + // `{allOf: [...]}` (no redundant `type: object`) would skip + // validation entirely even though the flag layer correctly flattens + // it — caught by Devin's review on PR #124. The parser-side mirror + // of this condition lives at `flatten_body_params_prefix`. + let has_all_of = !prop_schema.all_of.is_empty(); + if has_all_of + || (prop_schema.prop_type.as_deref() == Some("object") + && !prop_schema.properties.is_empty()) + { + if let Value::Object(obj) = value { + if has_all_of { + let (merged_props, merged_required) = merge_property_all_of(prop_schema, doc); + validate_properties(obj, &merged_props, &merged_required, doc, path, errors); + } else { + validate_properties(obj, &prop_schema.properties, &[], doc, path, errors); + } + } else if has_all_of { + // Typeless `{allOf: [...]}` property has no `prop_type` to + // catch the mismatch at step 2, so a non-object value would + // otherwise pass silently. Mirror `validate_value`'s + // top-level error so the user gets the same diagnostic + // regardless of where in the body tree the typeless allOf + // sits. Caught by Devin's review on PR #124. + errors.push(format!("{path}: Expected object")); + } + } + + // 5. Enum validation + if let Some(enum_values) = &prop_schema.enum_values { + if let Value::String(s) = value { + if !enum_values.contains(s) { + errors.push(format!( + "{path}: Value '{s}' is not a valid enum member. Valid options: {enum_values:?}" + )); + } + } + } +} + +fn get_value_type(val: &Value) -> &'static str { + match val { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(n) if n.is_f64() => "number (float)", + Value::Number(_) => "integer", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Open `file_path` for binary-response writing while refusing to write +/// through anything that isn't a fresh-or-existing single-linked regular +/// file the CWD-validated path points at directly. +/// +/// A server-controlled Content-Disposition filename or the predictable +/// `download.` default could otherwise be aimed at a pre-planted: +/// * **symlink** — `voice.mp3 -> ~/.ssh/authorized_keys`. `O_NOFOLLOW` +/// in the open flags makes the kernel reject this atomically (`ELOOP`). +/// * **hardlink** — `download.mp3` (link count 2) sharing an inode with +/// a victim file. `O_NOFOLLOW` does NOT catch this (no symlink in the +/// resolution chain), so we open *without* `O_TRUNC`, `fstat` the fd, +/// and refuse if `nlink > 1` before any truncation. +/// * **FIFO / device / socket** — `mkfifo download.mp3` would block +/// `open(O_WRONLY)` indefinitely with no reader. `O_NONBLOCK` in the +/// open flags returns `ENXIO` on reader-less FIFOs; an fstat-after-open +/// `is_file()` check catches FIFOs and devices that opened successfully. +/// `O_NONBLOCK` is a no-op for regular files per `open(2)`. +/// +/// Truncation happens via `set_len(0)` only after the file-type and link- +/// count checks pass, so a hardlinked or otherwise-suspicious target keeps +/// its bytes intact. +/// +/// On non-Unix platforms we fall back to a `symlink_metadata` pre-check +/// (small TOCTOU window, no readily-available hardlink API); FIFO and +/// hardlink refusal there is a follow-up. +async fn create_file_no_follow(file_path: &std::path::Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let mut opts = tokio::fs::OpenOptions::new(); + opts.write(true) + .create(true) + // No O_TRUNC at open time — we truncate explicitly via set_len + // only after the file-type and link-count checks below pass. + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + let file = match opts.open(file_path).await { + Ok(f) => f, + Err(e) if matches!(e.raw_os_error(), Some(c) if c == libc::ELOOP) => { + return Err(CliError::Validation(format!( + "Refused to write to '{}': path is a symbolic link", + file_path.display() + ))); + } + Err(e) if matches!(e.raw_os_error(), Some(c) if c == libc::ENXIO) => { + return Err(CliError::Validation(format!( + "Refused to write to '{}': path is a FIFO with no reader", + file_path.display() + ))); + } + Err(e) => { + return Err(anyhow::Error::from(e) + .context("Failed to create output file") + .into()); + } + }; + + let meta = file + .metadata() + .await + .context("Failed to stat output file")?; + if !meta.file_type().is_file() { + return Err(CliError::Validation(format!( + "Refused to write to '{}': not a regular file", + file_path.display() + ))); + } + if meta.nlink() > 1 { + return Err(CliError::Validation(format!( + "Refused to write to '{}': has {} hardlinks", + file_path.display(), + meta.nlink(), + ))); + } + // Clear O_NONBLOCK now that we've confirmed the target is a normal + // regular file. The flag was only needed to prevent open() blocking + // on a reader-less FIFO; if it stayed set on the fd, FUSE-backed + // filesystems (sshfs, gcsfuse, mountpoint-s3) can honor O_NONBLOCK + // on write(2) and surface spurious EAGAIN/WouldBlock errors from + // write_all. + unsafe { + use std::os::unix::io::AsRawFd; + let raw_fd = file.as_raw_fd(); + let flags = libc::fcntl(raw_fd, libc::F_GETFL); + if flags >= 0 { + let _ = libc::fcntl(raw_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + } + // Safe to truncate now that we've verified the target is a single- + // linked regular file. set_len(0) is a no-op on a fresh O_CREAT. + file.set_len(0) + .await + .context("Failed to truncate output file")?; + Ok(file) + } + #[cfg(not(unix))] + { + if let Ok(meta) = tokio::fs::symlink_metadata(file_path).await { + if meta.file_type().is_symlink() { + return Err(CliError::Validation(format!( + "Refused to write to '{}': path is a symbolic link", + file_path.display() + ))); + } + } + tokio::fs::File::create(file_path) + .await + .context("Failed to create output file") + .map_err(Into::into) + } +} + +/// Parse an RFC 6266 `Content-Disposition` value and return a sanitized +/// `filename` hint. +/// +/// Recognized: +/// - `attachment; filename="custom-voice.mp3"` (quoted) +/// - `attachment; filename=custom-voice.mp3` (unquoted token) +/// - `inline; filename="voice.mp3"` (inline disposition) +/// - `attachment; Filename="voice.mp3"` (case-insensitive name) +/// - `attachment; filename="hello;world.mp3"` (`;` inside quotes) +/// - `attachment; filename*=UTF-8''%E5%A3%B0.mp3` (RFC 5987 UTF-8) +/// - `attachment; filename*=ISO-8859-1''cafe.mp3` (RFC 5987 ISO-8859-1) +/// +/// Per RFC 6266 §4.3, `filename*` is preferred over `filename` when both +/// are present. Per RFC 7578 §4.2, a `form-data` disposition (multipart +/// upload variant) is not honored on responses. When the first `filename` +/// occurrence sanitizes to None we keep iterating and pick the next valid +/// one rather than letting an empty value shadow it. +fn extract_content_disposition_filename(header_value: &str) -> Option { + let (disposition_type, params) = split_content_disposition(header_value); + if disposition_type.eq_ignore_ascii_case("form-data") { + return None; + } + + let mut filename_star: Option = None; + let mut filename: Option = None; + for (name, value) in params { + if name.eq_ignore_ascii_case("filename*") && filename_star.is_none() { + if let Some(decoded) = decode_rfc5987_value(&value) { + if let Some(safe) = sanitize_server_supplied_filename(&decoded) { + filename_star = Some(safe); + } + } + } else if name.eq_ignore_ascii_case("filename") && filename.is_none() { + if let Some(safe) = sanitize_server_supplied_filename(&value) { + filename = Some(safe); + } + } + } + filename_star.or(filename) +} + +/// Split a Content-Disposition header into `(disposition_type, params)` +/// with RFC 7230 quoted-string awareness — `;` inside DQUOTE is preserved +/// and the standard `\X` escape inside quoted-strings is unescaped. +fn split_content_disposition(header: &str) -> (String, Vec<(String, String)>) { + let mut tokens: Vec = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut chars = header.chars(); + while let Some(c) = chars.next() { + if c == '"' { + in_quotes = !in_quotes; + current.push(c); + } else if c == '\\' && in_quotes { + // Quoted-pair: the backslash escapes the next char per RFC 7230. + // Both the backslash and the escaped char survive into `current`; + // the value-unquote step below strips the escape. + current.push(c); + if let Some(next) = chars.next() { + current.push(next); + } + } else if c == ';' && !in_quotes { + tokens.push(std::mem::take(&mut current)); + } else { + current.push(c); + } + } + if !current.is_empty() { + tokens.push(current); + } + + let mut iter = tokens.into_iter(); + let disposition_type = iter.next().unwrap_or_default().trim().to_string(); + let params: Vec<(String, String)> = iter + .filter_map(|t| { + let t = t.trim(); + let eq = t.find('=')?; + let name = t[..eq].trim().to_string(); + let raw = t[eq + 1..].trim(); + let value = unquote_parameter_value(raw); + Some((name, value)) + }) + .collect(); + (disposition_type, params) +} + +/// Unwrap a DQUOTE-quoted parameter value and undo `\X` -> `X` escapes per +/// RFC 7230 quoted-string. Unquoted token values are returned as-is. +fn unquote_parameter_value(raw: &str) -> String { + if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') { + let inner = &raw[1..raw.len() - 1]; + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + out.push(next); + } + } else { + out.push(c); + } + } + out + } else { + raw.to_string() + } +} + +/// Decode an RFC 5987 ext-value of the form `charset'language'pct-encoded`. +/// We support UTF-8 and ISO-8859-1 (the two charsets the RFC singles out); +/// other charsets fall through as None so the caller can use the ASCII +/// `filename=` fallback. +fn decode_rfc5987_value(raw: &str) -> Option { + let mut parts = raw.splitn(3, '\''); + let charset = parts.next()?.to_ascii_uppercase(); + let _lang = parts.next()?; + let encoded = parts.next()?; + + let bytes = percent_decode_bytes(encoded)?; + match charset.as_str() { + "UTF-8" => String::from_utf8(bytes).ok(), + "ISO-8859-1" => Some(bytes.into_iter().map(|b| b as char).collect()), + _ => None, + } +} + +/// Strict percent-decoder for RFC 5987 value-chars: `%HH` must be two hex +/// digits; any other non-ASCII byte invalidates the value (returns None). +fn percent_decode_bytes(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '%' { + let h1 = chars.next()?.to_digit(16)?; + let h2 = chars.next()?.to_digit(16)?; + out.push(((h1 << 4) | h2) as u8); + } else if c.is_ascii() { + out.push(c as u8); + } else { + return None; + } + } + Some(out) +} + +/// Reduce a server-supplied filename to a safe basename, dropping any +/// directory components, control characters, or empty / dot-only names. +/// The server picks the *name*; the client always picks the *directory*. +/// +/// Rejection rules (server-controlled input must be conservative): +/// - empty or whitespace-only +/// - ASCII control chars (`is_control`, General_Category=Cc) +/// - Unicode bidi / format chars (U+200E/F, U+202A–E, U+2066–9) — these +/// are spoof vectors for displayed-name vs actual-extension mismatch +/// - embedded `\` — keeps Unix/Windows behavior aligned; on Windows this +/// would be a path separator, so the safe rule is to reject it everywhere +/// - basename equal to `.` or `..` +/// - basename starting with `.` — prevents server-driven dotfile clobber +/// (`.env`, `.bashrc`, `.gitignore`, etc.) in the user's CWD +fn sanitize_server_supplied_filename(raw: &str) -> Option { + let s = raw.trim(); + if s.is_empty() || s.chars().any(is_unsafe_filename_char) { + return None; + } + let basename = std::path::Path::new(s).file_name()?.to_str()?; + if basename.is_empty() + || basename == "." + || basename == ".." + || basename.starts_with('.') + { + return None; + } + Some(basename.to_string()) +} + +/// A character we will never accept in a server-supplied filename: ASCII +/// controls, the C1 controls (already caught by is_control), bidi/format +/// overrides that cause spoofed displayed names, and path separators of +/// either platform's convention. +fn is_unsafe_filename_char(c: char) -> bool { + if c.is_control() || c == '\\' { + return true; + } + matches!( + c, + // RFC 3987 bidi controls — LRM/RLM, LRE/RLE/PDF/LRO/RLO, isolates. + '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' + ) +} + +/// True iff `mime` (already lowercased) is exactly `target`, or `target` +/// followed by a parameter delimiter (`;`) or whitespace. Used to anchor +/// MIME-type matching so e.g. `audio/mpegurl` does not collide with +/// `audio/mpeg`. +fn is_media_type(mime: &str, target: &str) -> bool { + if let Some(rest) = mime.strip_prefix(target) { + rest.is_empty() || rest.starts_with(';') || rest.starts_with(char::is_whitespace) + } else { + false + } +} + +pub fn mime_to_extension(mime: &str) -> &str { + // Lowercased lookup so `Audio/MPEG` and `audio/mpeg` map the same way — + // RFC 6838 declares media types case-insensitive. The cheap lowercase is + // amortized by the single allocation per response (binary downloads are + // rare relative to JSON), and a missing branch silently degrading audio + // responses to `.bin` was the original FER-10871 bug. + let m = mime.to_ascii_lowercase(); + // Audio / video — checked before the generic `mpeg` / `mp4` substrings + // because `audio/mpeg` and `video/mpeg` must not collide. + // + // The exact-match-with-optional-params helper (`is_media_type`) is used + // for the foundational `audio/mpeg` / `audio/wav` / etc. branches so a + // related-but-distinct subtype like `audio/mpegurl` (M3U playlist) or + // `audio/wavpack` (lossless codec) does NOT collapse into the wrong + // extension via prefix matching. Variant subtypes (`audio/x-wav`, + // `audio/wave`, `audio/x-m4a`, …) are enumerated explicitly. + if is_media_type(&m, "audio/mpegurl") || is_media_type(&m, "audio/x-mpegurl") { + "m3u" + } else if is_media_type(&m, "audio/mpeg") || is_media_type(&m, "audio/mp3") { + "mp3" + } else if is_media_type(&m, "audio/wavpack") { + "wv" + } else if is_media_type(&m, "audio/wav") + || is_media_type(&m, "audio/x-wav") + || is_media_type(&m, "audio/wave") + { + "wav" + } else if m.starts_with("audio/ogg") || m.starts_with("audio/vorbis") { + "ogg" + } else if m.starts_with("audio/opus") { + "opus" + } else if m.starts_with("audio/flac") || m.starts_with("audio/x-flac") { + "flac" + } else if m.starts_with("audio/aac") || m.starts_with("audio/x-aac") { + "aac" + } else if m.starts_with("audio/mp4") || m.starts_with("audio/x-m4a") { + "m4a" + } else if m.starts_with("audio/webm") { + "weba" + } else if m.starts_with("video/mp4") { + "mp4" + } else if m.starts_with("video/webm") { + "webm" + } else if m.starts_with("video/quicktime") { + "mov" + } else if m.starts_with("video/x-matroska") { + "mkv" + } else if m.starts_with("video/mpeg") { + "mpeg" + } else if m.contains("pdf") { + "pdf" + } else if m.contains("png") { + "png" + } else if m.contains("jpeg") || m.contains("jpg") { + "jpg" + } else if m.contains("gif") { + "gif" + } else if m.contains("svg") { + "svg" + } else if m.contains("webp") { + "webp" + } else if m.contains("csv") { + "csv" + } else if m.contains("zip") { + "zip" + } else if m.contains("xml") { + "xml" + } else if m.contains("html") { + "html" + } else if m.contains("plain") { + "txt" + } else if m.contains("octet-stream") { + "bin" + } else if m.contains("spreadsheet") || m.contains("xlsx") { + "xlsx" + } else if m.contains("document") || m.contains("docx") { + "docx" + } else if m.contains("presentation") || m.contains("pptx") { + "pptx" + } else if m.contains("script") { + "json" + } else { + "bin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{ + JsonSchema, JsonSchemaProperty, MethodParameter, RestDescription, RestMethod, + }; + use serde_json::json; + + // --------------------------------------------------------------- + // Retry helpers (`x-fern-retries`) + // --------------------------------------------------------------- + + fn enabled_cfg() -> RetriesConfig { + RetriesConfig::default() + } + + #[test] + fn test_is_retryable_status_set_matches_docs() { + // All 5xx plus 408 and 429 are retryable (FER-10521). + for s in [408u16, 429, 500, 501, 502, 503, 504, 505, 599] { + assert!(is_retryable_status(s), "{s} should retry"); + } + // 4xx client errors (except 408/429) won't change on retry \u2014 see is_retryable_status + // and 2xx/3xx are obviously terminal. + for s in [200u16, 301, 400, 401, 403, 404, 422, 425] { + assert!(!is_retryable_status(s), "{s} should NOT retry"); + } + } + + #[test] + fn test_method_allows_retry_idempotent_methods() { + // HTTP-spec-idempotent methods retry regardless of the + // `x-fern-idempotent` extension. + for m in ["GET", "HEAD", "OPTIONS", "DELETE", "PUT"] { + assert!(method_allows_retry(m, false), "{m} should retry by default"); + } + } + + #[test] + fn test_method_allows_retry_non_idempotent_methods_only_when_marked() { + // POST/PATCH only retry when the spec marks the op idempotent. + for m in ["POST", "PATCH"] { + assert!(!method_allows_retry(m, false), "{m} should NOT retry by default"); + assert!(method_allows_retry(m, true), "{m} retries when x-fern-idempotent"); + } + } + + #[test] + fn test_binary_body_is_stdin() { + // Stdin sentinels — retries must be disabled. + assert!(binary_body_is_stdin(Some("-"))); + assert!(binary_body_is_stdin(Some("@-"))); + // File paths — retries are safe (re-opens the file). + assert!(!binary_body_is_stdin(Some("/tmp/audio.mp3"))); + assert!(!binary_body_is_stdin(Some("@/tmp/audio.mp3"))); + // No binary body at all — retries decided by other policy. + assert!(!binary_body_is_stdin(None)); + } + + #[test] + fn test_multipart_has_stdin() { + // File part with `-` — retries must be disabled. + assert!(multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "-".into(), + content_type: None, + }]))); + // File part with `@-` — also stdin. + assert!(multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "@-".into(), + content_type: None, + }]))); + // File part with real path — retries are safe. + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "/tmp/upload.bin".into(), + content_type: None, + }]))); + // Text-only parts — retries are safe. + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::Text { + name: "purpose".into(), + value: "test".into(), + content_type: None, + }]))); + // Mixed: one stdin file + one text — still disables retries. + assert!(multipart_has_stdin(&Some(vec![ + MultipartPart::Text { + name: "purpose".into(), + value: "test".into(), + content_type: None, + }, + MultipartPart::File { + name: "file".into(), + path: "-".into(), + content_type: None, + }, + ]))); + // No multipart parts at all. + assert!(!multipart_has_stdin(&None)); + + // FER-10532: explicit-scheme `-` is a literal filename, NOT stdin. + // `@file://-` and `@data://-` both fail the stdin check so retries + // stay enabled (the file read is replayable, unlike a pipe). + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "@file://-".into(), + content_type: None, + }]))); + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "@data://-".into(), + content_type: None, + }]))); + // FER-10532: explicit-scheme path that happens to be `-` somewhere + // else (e.g. `@file://./-.bin`) is also a real path. + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "@file:///tmp/audio.txt".into(), + content_type: None, + }]))); + // FER-10436: `\@-` is the escape — literal value `@-`, not stdin. + assert!(!multipart_has_stdin(&Some(vec![MultipartPart::File { + name: "file".into(), + path: "\\@-".into(), + content_type: None, + }]))); + } + + #[test] + fn test_file_part_mime_defaults_and_override() { + // Neither an encoding entry nor a usable file name → OAS binary default. + assert_eq!(file_part_mime(None, None), "application/octet-stream"); + // An encoding-supplied content type wins, even over the extension. + assert_eq!(file_part_mime(Some("image/png"), None), "image/png"); + assert_eq!(file_part_mime(Some("text/plain"), None), "text/plain"); + assert_eq!( + file_part_mime(Some("application/pdf"), Some("notes.txt")), + "application/pdf" + ); + } + + #[test] + fn test_file_part_mime_infers_from_extension_when_spec_is_silent() { + // Most specs omit `encoding` entirely. Labelling these parts + // `application/octet-stream` makes servers that validate a part's media + // type reject the upload — every type below is one such server's + // allow-list entry that the CLI previously could not satisfy. + assert_eq!(file_part_mime(None, Some("doc.txt")), "text/plain"); + assert_eq!(file_part_mime(None, Some("paper.pdf")), "application/pdf"); + assert_eq!(file_part_mime(None, Some("notes.md")), "text/markdown"); + assert_eq!(file_part_mime(None, Some("page.html")), "text/html"); + assert_eq!(file_part_mime(None, Some("book.epub")), "application/epub+zip"); + assert_eq!( + file_part_mime(None, Some("report.docx")), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ); + // Audio, for the speech-to-text and dubbing endpoints. + assert_eq!(file_part_mime(None, Some("clip.mp3")), "audio/mpeg"); + assert_eq!(file_part_mime(None, Some("clip.m4a")), "audio/mp4"); + assert_eq!(file_part_mime(None, Some("clip.flac")), "audio/flac"); + // Unrecognized or absent extension falls back to the OAS default. + assert_eq!(file_part_mime(None, Some("archive.xyz")), "application/octet-stream"); + assert_eq!(file_part_mime(None, Some("README")), "application/octet-stream"); + } + + #[test] + fn test_file_part_mime_is_case_insensitive_and_injection_safe() { + assert_eq!(file_part_mime(None, Some("CLIP.MP3")), "audio/mpeg"); + // A control character in a spec-supplied value cannot smuggle a header + // break into the multipart preamble. + assert_eq!( + file_part_mime(Some("text/plain\r\nX-Injected: 1"), None), + "text/plainX-Injected: 1" + ); + assert_eq!(file_part_mime(Some("\r\n"), None), "application/octet-stream"); + } + + /// Send a built multipart form to a local mock server and return the raw + /// captured body so we can assert the per-part framing the wire carries. + /// reqwest serializes multipart forms as a stream, so the only faithful + /// way to inspect the bytes is to actually transmit them. + async fn multipart_body_string(parts: Vec) -> String { + use wiremock::matchers::method as wm_method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(wm_method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + let form = build_multipart_form(&parts).await.unwrap(); + client + .post(format!("{}/upload", server.uri())) + .multipart(form) + .send() + .await + .unwrap(); + + let received = server.received_requests().await.unwrap(); + String::from_utf8_lossy(&received[0].body).into_owned() + } + + #[tokio::test] + async fn test_build_multipart_form_text_part_uses_encoding_content_type() { + // A text part with an explicit encoding contentType emits that + // Content-Type header instead of the default text/plain. + let body = multipart_body_string(vec![MultipartPart::Text { + name: "metadata".into(), + value: "{\"k\":1}".into(), + content_type: Some("application/json".into()), + }]) + .await; + assert!( + body.contains("Content-Disposition: form-data; name=\"metadata\""), + "text part should carry its Content-Disposition; got: {body}" + ); + assert!( + body.contains("Content-Type: application/json"), + "text part Content-Type should come from encoding; got: {body}" + ); + assert!(body.contains("{\"k\":1}"), "value should be in body; got: {body}"); + } + + #[tokio::test] + async fn test_build_multipart_form_file_part_default_octet_stream() { + // A file part without an encoding entry defaults to octet-stream. + let tmp = std::env::temp_dir().join("fern_multipart_default.bin"); + std::fs::write(&tmp, b"payload-bytes").unwrap(); + let body = multipart_body_string(vec![MultipartPart::File { + name: "file".into(), + path: tmp.to_string_lossy().into_owned(), + content_type: None, + }]) + .await; + let _ = std::fs::remove_file(&tmp); + assert!( + body.contains("Content-Disposition: form-data; name=\"file\""), + "file part should carry its Content-Disposition; got: {body}" + ); + assert!( + body.contains("Content-Type: application/octet-stream"), + "file part with an unrecognized extension should default to octet-stream; got: {body}" + ); + assert!(body.contains("payload-bytes"), "file bytes should stream; got: {body}"); + } + + #[tokio::test] + async fn test_build_multipart_form_file_part_infers_content_type_from_extension() { + // The regression that made uploads unusable: with no `encoding` entry — + // which is what most specs have — every part went out as + // `application/octet-stream`, and servers that validate a part's media + // type rejected the request outright. + let tmp = std::env::temp_dir().join("fern_multipart_inferred.txt"); + std::fs::write(&tmp, b"plain-text-payload").unwrap(); + let body = multipart_body_string(vec![MultipartPart::File { + name: "file".into(), + path: tmp.to_string_lossy().into_owned(), + content_type: None, + }]) + .await; + let _ = std::fs::remove_file(&tmp); + assert!( + body.contains("Content-Type: text/plain"), + "a .txt part should be labelled text/plain; got: {body}" + ); + assert!( + !body.contains("Content-Type: application/octet-stream"), + "the octet-stream default must not survive a recognized extension; got: {body}" + ); + } + + #[tokio::test] + async fn test_build_multipart_form_file_part_honors_encoding_content_type() { + // A file part with an encoding contentType overrides the default. + let tmp = std::env::temp_dir().join("fern_multipart_override.png"); + std::fs::write(&tmp, b"\x89PNG").unwrap(); + let body = multipart_body_string(vec![MultipartPart::File { + name: "file".into(), + path: tmp.to_string_lossy().into_owned(), + content_type: Some("image/png".into()), + }]) + .await; + let _ = std::fs::remove_file(&tmp); + assert!( + body.contains("Content-Type: image/png"), + "file part Content-Type should come from encoding; got: {body}" + ); + } + + #[test] + fn test_parse_retry_after_numeric_seconds() { + let now = std::time::SystemTime::now(); + let d = parse_retry_after("5", now).expect("numeric form"); + assert_eq!(d, std::time::Duration::from_secs(5)); + } + + #[test] + fn test_parse_retry_after_zero() { + // `Retry-After: 0` means "retry now". + let d = parse_retry_after("0", std::time::SystemTime::now()).unwrap(); + assert_eq!(d, std::time::Duration::ZERO); + } + + #[test] + fn test_parse_retry_after_whitespace_and_empty() { + assert!(parse_retry_after("", std::time::SystemTime::now()).is_none()); + // Common server spelling has surrounding whitespace; we trim. + let d = parse_retry_after(" 10 ", std::time::SystemTime::now()).unwrap(); + assert_eq!(d, std::time::Duration::from_secs(10)); + } + + #[test] + fn test_parse_retry_after_http_date_future() { + // 60 seconds in the future expressed as IMF-fixdate. + let now = std::time::SystemTime::now(); + let target = now + std::time::Duration::from_secs(60); + let fmt = httpdate::fmt_http_date(target); + let d = parse_retry_after(&fmt, now).expect("http-date form parses"); + // Allow slight skew because `fmt_http_date` rounds to seconds. + assert!(d.as_secs() >= 59 && d.as_secs() <= 60, "got {d:?}"); + } + + #[test] + fn test_parse_retry_after_http_date_in_the_past_clamps_to_zero() { + // A server that emits a past timestamp \u2014 either clock-skew or + // an unusual "you can retry now" gesture \u2014 should collapse to + // an immediate retry rather than underflow. + let now = std::time::SystemTime::now(); + let target = now - std::time::Duration::from_secs(60); + let fmt = httpdate::fmt_http_date(target); + let d = parse_retry_after(&fmt, now).expect("past http-date parses"); + assert_eq!(d, std::time::Duration::ZERO); + } + + #[test] + fn test_parse_retry_after_garbage_returns_none() { + assert!( + parse_retry_after("nonsense", std::time::SystemTime::now()).is_none(), + "bad header surfaces None so the backoff fallback applies" + ); + } + + #[test] + fn test_compute_backoff_delay_no_jitter_is_deterministic() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 5, + base_delay_ms: 100, + factor: 2.0, + jitter: 0.0, + }; + // attempt=0 \u2192 100ms; attempt=1 \u2192 200; attempt=2 \u2192 400; ... + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::from_millis(100) + ); + assert_eq!( + compute_backoff_delay_with_rand(1, &cfg, 0.5), + std::time::Duration::from_millis(200) + ); + assert_eq!( + compute_backoff_delay_with_rand(2, &cfg, 0.5), + std::time::Duration::from_millis(400) + ); + assert_eq!( + compute_backoff_delay_with_rand(3, &cfg, 0.5), + std::time::Duration::from_millis(800) + ); + } + + #[test] + fn test_compute_backoff_delay_jitter_symmetric_around_raw() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 5, + base_delay_ms: 100, + factor: 2.0, + jitter: 0.5, + }; + // rand=0.5 \u2192 offset is zero \u2192 raw delay. + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::from_millis(100) + ); + // rand=0.0 \u2192 subtract half the jitter span. + // span = 100 * 0.5 = 50; offset = (0 - 0.5) * 50 = -25 \u2192 75ms + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.0), + std::time::Duration::from_millis(75) + ); + // rand=1.0 \u2192 add half the jitter span. offset = +25 \u2192 125ms + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 1.0), + std::time::Duration::from_millis(125) + ); + } + + #[test] + fn test_compute_backoff_delay_disabled_returns_zero() { + let cfg = RetriesConfig::disabled(); + assert_eq!( + compute_backoff_delay_with_rand(0, &cfg, 0.5), + std::time::Duration::ZERO + ); + } + + #[test] + fn test_compute_backoff_delay_default_entropy_produces_jitter() { + // Regression: an earlier implementation sampled entropy from + // `Instant::now().elapsed()`, which always returns ~0 nanos and + // pinned the jitter sample to a constant — defeating jitter. + // Sample the live `compute_backoff_delay` 64 times with a wide + // jitter band and assert we see at least two distinct values. + let cfg = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 1000, + factor: 1.0, + jitter: 1.0, + }; + let mut samples = std::collections::HashSet::new(); + for _ in 0..64 { + samples.insert(compute_backoff_delay(0, &cfg).as_millis()); + // Tiny pause so the wall-clock sub-second component + // advances between samples in fast CI environments. + std::thread::sleep(std::time::Duration::from_micros(50)); + } + assert!( + samples.len() > 1, + "expected variance in jitter samples, got {samples:?}", + ); + } + + #[test] + fn test_decide_retry_no_retry_flag_short_circuits() { + // `--no-retry` is the user-facing debug opt-out. Mirrors the + // PR description's open design question: yes, full opt-out + // even for network errors so users can debug. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, /*no_retry=*/ true); + assert!(d.is_none(), "--no-retry disables all retries"); + } + + #[test] + fn test_decide_retry_disabled_config_no_retry() { + let cfg = RetriesConfig::disabled(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_none(), "disabled config never retries"); + } + + #[test] + fn test_decide_retry_max_attempts_cap() { + let cfg = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 1, + factor: 1.0, + jitter: 0.0, + }; + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + // attempt 0 -> retry (allowed) + assert!(decide_retry(0, &outcome, &cfg, "GET", false, false).is_some()); + // attempt 1 -> retry (allowed) + assert!(decide_retry(1, &outcome, &cfg, "GET", false, false).is_some()); + // attempt 2 -> done; we've used all 3 attempts. Stop. + assert!(decide_retry(2, &outcome, &cfg, "GET", false, false).is_none()); + // attempt 3+ -> never. Defensive. + assert!(decide_retry(3, &outcome, &cfg, "GET", false, false).is_none()); + } + + #[test] + fn test_decide_retry_retryable_status_get_retries() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_non_retryable_status_no_retry() { + // 401 Unauthorized never retries \u2014 wait won't make creds valid. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(401), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_post_503_without_idempotent_no_retry() { + // Plain POST got 503 \u2014 the server may have processed it. + // Don't retry without an explicit idempotent marker. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_post_503_with_idempotent_retries() { + // POST marked idempotent (x-fern-idempotent) is safe to retry. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", true, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_post_429_always_safe() { + // 429 means the server *didn't* process the request \u2014 always + // safe to retry regardless of method idempotency. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(429), + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_some(), "429 retries on non-idempotent methods"); + } + + #[test] + fn test_decide_retry_network_error_get_retries() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false); + assert!(d.is_some()); + } + + #[test] + fn test_decide_retry_network_error_post_without_idempotent_no_retry() { + // Network failure on a POST: ambiguous whether the server got + // the request. Mirror the per-method policy here too. + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + let d = decide_retry(0, &outcome, &cfg, "POST", false, false); + assert!(d.is_none()); + } + + #[test] + fn test_decide_retry_honors_retry_after_numeric() { + // When the server provides Retry-After, honor it instead of + // the computed backoff (the server knows better than we do). + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: Some("7"), + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false) + .expect("should retry"); + assert_eq!(d, std::time::Duration::from_secs(7)); + } + + #[test] + fn test_decide_retry_falls_back_to_backoff_when_retry_after_invalid() { + let cfg = enabled_cfg(); + let outcome = RetryOutcome { + status: Some(503), + retry_after: Some("not-a-number"), + }; + let d = decide_retry(0, &outcome, &cfg, "GET", false, false) + .expect("should retry"); + // Falls back to backoff math \u2014 not zero, not the parsed value. + assert!(d > std::time::Duration::ZERO); + } + + #[test] + fn test_binary_body_source_plain_path() { + match BinaryBodySource::parse("/tmp/audio.mp3") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "/tmp/audio.mp3"); + assert_eq!(mode, AtMode::Auto); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_at_path_strips_prefix() { + match BinaryBodySource::parse("@/tmp/audio.mp3") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "/tmp/audio.mp3"); + assert_eq!(mode, AtMode::Auto); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_dash_is_stdin() { + assert!(matches!(BinaryBodySource::parse("-"), BinaryBodySource::Stdin)); + } + + #[test] + fn test_binary_body_source_at_dash_is_stdin() { + // curl's spelling for stdin is `@-`; we accept it as an alias for `-`. + assert!(matches!(BinaryBodySource::parse("@-"), BinaryBodySource::Stdin)); + } + + #[test] + fn test_binary_body_source_double_at_is_literal_at_path() { + // Only the first `@` is stripped — matches curl's behavior for filenames + // that legitimately start with `@`. + match BinaryBodySource::parse("@@weird-name.mp3") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "@weird-name.mp3"); + assert_eq!(mode, AtMode::Auto); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_backslash_at_is_escaped_literal_path() { + // `\@literal` reaches the file source as a literal `@literal` path + // (rather than triggering the `@` strip). Users can pass a path that + // literally begins with `@` this way. FER-10436. + match BinaryBodySource::parse("\\@weird") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "@weird"); + assert_eq!(mode, AtMode::Auto); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + // FER-10532 — explicit scheme prefixes carry an [`AtMode`] through to the + // executor. The path is stripped to the inner filesystem path; `-` is no + // longer the stdin sentinel under an explicit scheme. + + #[test] + fn test_binary_body_source_file_scheme_is_text_mode() { + match BinaryBodySource::parse("@file:///tmp/audio.txt") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "/tmp/audio.txt"); + assert_eq!(mode, AtMode::Text); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_data_scheme_is_data_mode() { + match BinaryBodySource::parse("@data:///tmp/audio.bin") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "/tmp/audio.bin"); + assert_eq!(mode, AtMode::Data); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + #[test] + fn test_binary_body_source_file_scheme_dash_is_not_stdin() { + // `@file://-` is a literal file path named `-`, not stdin. Only the + // bare `Auto`-mode `@-` (and plain `-`) trigger stdin. + match BinaryBodySource::parse("@file://-") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "-"); + assert_eq!(mode, AtMode::Text); + } + BinaryBodySource::Stdin => panic!("explicit-scheme `-` must not be stdin"), + } + } + + #[test] + fn test_binary_body_source_data_scheme_dash_is_not_stdin() { + match BinaryBodySource::parse("@data://-") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "-"); + assert_eq!(mode, AtMode::Data); + } + BinaryBodySource::Stdin => panic!("explicit-scheme `-` must not be stdin"), + } + } + + #[test] + fn test_binary_body_source_backslash_at_file_scheme_is_literal_path() { + // `\@file://x` escapes the *whole* `@`-prefix grammar — the value + // becomes the literal path string `@file://x` (FER-10436 escape + // semantics). No scheme is parsed; mode stays `Auto`. + match BinaryBodySource::parse("\\@file:///etc/hosts") { + BinaryBodySource::File { path, mode } => { + assert_eq!(path.as_ref(), "@file:///etc/hosts"); + assert_eq!(mode, AtMode::Auto); + } + BinaryBodySource::Stdin => panic!("expected File"), + } + } + + // --------------------------------------------------------------- + // FER-10436 — `@file` references inside object-shorthand JSON + // --------------------------------------------------------------- + + #[test] + fn test_strip_or_escape_at_strips_curl_prefix() { + // The common case — curl-style `@` strips the prefix and + // borrows the rest of the input (no allocation). + let out = strip_or_escape_at("@/path/to/file"); + assert_eq!(out.as_ref(), "/path/to/file"); + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + } + + #[test] + fn test_strip_or_escape_at_passes_through_plain_path() { + let out = strip_or_escape_at("/path/to/file"); + assert_eq!(out.as_ref(), "/path/to/file"); + assert!(matches!(out, std::borrow::Cow::Borrowed(_))); + } + + #[test] + fn test_strip_or_escape_at_rewrites_backslash_escape() { + // `\@literal` → `@literal` (allocates; the rewrite produces a + // string the caller can treat as a literal value). + let out = strip_or_escape_at("\\@literal"); + assert_eq!(out.as_ref(), "@literal"); + assert!(matches!(out, std::borrow::Cow::Owned(_))); + } + + #[test] + fn test_strip_or_escape_at_strips_at_dash() { + let out = strip_or_escape_at("@-"); + assert_eq!(out.as_ref(), "-"); + } + + #[test] + fn test_is_escaped_literal_detects_backslash_at_prefix() { + assert!(is_escaped_literal("\\@anything")); + assert!(!is_escaped_literal("@anything")); + assert!(!is_escaped_literal("plain")); + assert!(!is_escaped_literal("")); + } + + #[test] + fn test_resolve_file_refs_reads_utf8_file_inside_nested_string() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"hello from disk").unwrap(); + let at_path = format!("@{}", tmp.path().to_str().unwrap()); + + let mut value = json!({ + "outer": { + "pic": at_path, + "untouched": "verbatim", + } + }); + resolve_file_refs(&mut value).expect("should resolve"); + assert_eq!(value["outer"]["pic"], json!("hello from disk")); + assert_eq!(value["outer"]["untouched"], json!("verbatim")); + } + + #[test] + fn test_resolve_file_refs_base64_encodes_binary_file() { + // Bytes that are NOT valid UTF-8 must round-trip through base64 + // so the resulting JSON string is still well-formed. + let tmp = tempfile::NamedTempFile::new().unwrap(); + let raw_bytes: Vec = vec![0xff, 0xfe, 0xfd, 0x00, 0x01]; + std::fs::write(tmp.path(), &raw_bytes).unwrap(); + let at_path = format!("@{}", tmp.path().to_str().unwrap()); + + let mut value = json!({ "blob": at_path }); + resolve_file_refs(&mut value).expect("should resolve"); + let expected = BASE64.encode(&raw_bytes); + assert_eq!(value["blob"], json!(expected)); + } + + #[test] + fn test_resolve_file_refs_rewrites_backslash_escape_in_string() { + // `\@literal` rewrites to `@literal`; no filesystem call is made. + let mut value = json!({ "name": "\\@notapath" }); + resolve_file_refs(&mut value).expect("escape should not touch disk"); + assert_eq!(value["name"], json!("@notapath")); + } + + #[test] + fn test_resolve_file_refs_leaves_non_at_strings_unchanged() { + let mut value = json!({ "name": "fern", "n": 42, "ok": true }); + resolve_file_refs(&mut value).expect("ok"); + assert_eq!(value, json!({ "name": "fern", "n": 42, "ok": true })); + } + + #[test] + fn test_resolve_file_refs_missing_file_includes_path_and_pointer() { + // The error message must surface both the offending path and the + // JSON-Pointer location so callers can diagnose deeply-nested refs. + let missing = "/definitely/does/not/exist-fer10436.tmp"; + let at_path = format!("@{missing}"); + let mut value = json!({ "outer": { "pic": at_path } }); + let err = resolve_file_refs(&mut value).expect_err("missing file"); + let CliError::Validation(msg) = &err else { + panic!("expected Validation, got {err:?}"); + }; + assert!(msg.contains(missing), "error must include path: {msg}"); + assert!(msg.contains("/outer/pic"), "error must include JSON pointer: {msg}"); + } + + #[test] + fn test_resolve_file_refs_deep_pointer_is_correct() { + // Pointer must walk arrays (numeric indices) and nested objects + // and escape RFC-6901 metacharacters in keys. + let mut value = json!({ + "foo": { + "bar": [ + { "baz": "@/definitely/nope-fer10436" } + ] + } + }); + let err = resolve_file_refs(&mut value).expect_err("missing file"); + let CliError::Validation(msg) = &err else { + panic!("expected Validation, got {err:?}"); + }; + assert!( + msg.contains("/foo/bar/0/baz"), + "pointer must be /foo/bar/0/baz, got: {msg}" + ); + } + + #[test] + fn test_resolve_file_refs_escapes_special_keys_in_pointer() { + // RFC 6901 §4: `~` and `/` in keys must be escaped to `~0` and `~1` + // respectively in the pointer. + let mut map = serde_json::Map::new(); + let mut inner = serde_json::Map::new(); + inner.insert( + "a/b~c".to_string(), + json!("@/definitely/nope-fer10436-special"), + ); + map.insert("root".to_string(), Value::Object(inner)); + let mut value = Value::Object(map); + let err = resolve_file_refs(&mut value).expect_err("missing file"); + let CliError::Validation(msg) = &err else { + panic!("expected Validation, got {err:?}"); + }; + assert!( + msg.contains("/root/a~1b~0c"), + "pointer must escape `/` to `~1` and `~` to `~0`, got: {msg}" + ); + } + + #[test] + fn test_resolve_file_refs_rejects_control_chars_in_path() { + // An adversarial nested value must not bypass the path-safety net + // that every other file-read site enforces (binding.rs binary body, + // app.rs multipart). The error must surface before any disk I/O and + // must point at the offending JSON field so callers can fix it. + let mut value = json!({ "profile": { "pic": "@evil\x00path" } }); + let err = resolve_file_refs(&mut value).expect_err("must reject control char"); + let CliError::Validation(msg) = &err else { + panic!("expected Validation, got {err:?}"); + }; + assert!( + msg.contains("/profile/pic"), + "error must include JSON pointer: {msg}" + ); + assert!( + msg.contains("control"), + "error must mention control characters: {msg}" + ); + } + + // ----------------------------------------------------------------- + // FER-10532 — explicit `@file://` (UTF-8 only) and `@data://` (base64) + // scheme prefixes inside object-shorthand JSON values. + // ----------------------------------------------------------------- + + #[test] + fn test_resolve_file_refs_file_scheme_inlines_utf8_string() { + // `@file://` reads UTF-8 text and embeds the string verbatim, + // never base64. Same wire output as the `Auto` mode would produce + // for valid UTF-8 — the value of the scheme is the *guarantee*. + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), b"hello strict").unwrap(); + let at_path = format!("@file://{}", tmp.path().to_str().unwrap()); + + let mut value = json!({ "outer": { "pic": at_path } }); + resolve_file_refs(&mut value).expect("should resolve"); + assert_eq!(value["outer"]["pic"], json!("hello strict")); + } + + #[test] + fn test_resolve_file_refs_file_scheme_errors_on_non_utf8() { + // The defining behavior of `@file://`: refuse to silently base64 + // a binary payload. The error must surface the JSON pointer and + // a hint about `@data://` so the user knows the escape hatch. + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), [0xff, 0xfe, 0xfd]).unwrap(); + let at_path = format!("@file://{}", tmp.path().to_str().unwrap()); + + let mut value = json!({ "blob": at_path }); + let err = resolve_file_refs(&mut value).expect_err("must reject non-UTF-8"); + let CliError::Validation(msg) = &err else { + panic!("expected Validation, got {err:?}"); + }; + assert!( + msg.contains("not valid UTF-8"), + "error must say `not valid UTF-8`: {msg}" + ); + assert!( + msg.contains("@data://"), + "error must hint at @data:// escape hatch: {msg}" + ); + assert!(msg.contains("/blob"), "error must include JSON pointer: {msg}"); + } + + #[test] + fn test_resolve_file_refs_data_scheme_base64s_utf8_input() { + // `@data://` always base64-encodes, even on valid UTF-8 + // (otherwise it would be identical to `@file://`). This is the + // case Stainless documents for "API expects base64 of a text + // payload." + let tmp = tempfile::NamedTempFile::new().unwrap(); + let utf8_payload = b"hello fern"; + std::fs::write(tmp.path(), utf8_payload).unwrap(); + let at_path = format!("@data://{}", tmp.path().to_str().unwrap()); + + let mut value = json!({ "encoded": at_path }); + resolve_file_refs(&mut value).expect("should resolve"); + let expected = BASE64.encode(utf8_payload); + assert_eq!(value["encoded"], json!(expected)); + } + + #[test] + fn test_resolve_file_refs_data_scheme_base64s_binary_input() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let raw_bytes: Vec = vec![0xff, 0xfe, 0x00, 0x01]; + std::fs::write(tmp.path(), &raw_bytes).unwrap(); + let at_path = format!("@data://{}", tmp.path().to_str().unwrap()); + + let mut value = json!({ "blob": at_path }); + resolve_file_refs(&mut value).expect("should resolve"); + assert_eq!(value["blob"], json!(BASE64.encode(&raw_bytes))); + } + + #[test] + fn test_resolve_file_refs_backslash_at_file_scheme_is_literal() { + // `\@file://x` is a literal string `@file://x` — no scheme is + // parsed, no file is read. Same escape rule as `\@`. + let mut value = json!({ "k": "\\@file:///etc/hosts" }); + resolve_file_refs(&mut value).expect("escape should not touch disk"); + assert_eq!(value["k"], json!("@file:///etc/hosts")); + } + + #[test] + fn test_resolve_file_refs_backslash_at_data_scheme_is_literal() { + let mut value = json!({ "k": "\\@data:///etc/hosts" }); + resolve_file_refs(&mut value).expect("escape should not touch disk"); + assert_eq!(value["k"], json!("@data:///etc/hosts")); + } + + #[test] + fn test_parse_at_ref_recognises_schemes() { + // Direct coverage on the parser so each scheme has at least one + // unit test that doesn't have to spin up a filesystem. + match parse_at_ref("@file:///etc/hosts") { + AtRef::File { path, mode } => { + assert_eq!(path.as_ref(), "/etc/hosts"); + assert_eq!(mode, AtMode::Text); + } + _ => panic!("expected File(Text)"), + } + match parse_at_ref("@data:///etc/hosts") { + AtRef::File { path, mode } => { + assert_eq!(path.as_ref(), "/etc/hosts"); + assert_eq!(mode, AtMode::Data); + } + _ => panic!("expected File(Data)"), + } + match parse_at_ref("@/etc/hosts") { + AtRef::File { path, mode } => { + assert_eq!(path.as_ref(), "/etc/hosts"); + assert_eq!(mode, AtMode::Auto); + } + _ => panic!("expected File(Auto)"), + } + match parse_at_ref("\\@file:///etc/hosts") { + AtRef::Escaped(s) => assert_eq!(s, "@file:///etc/hosts"), + _ => panic!("expected Escaped"), + } + match parse_at_ref("plain-path") { + AtRef::Plain(s) => assert_eq!(s, "plain-path"), + _ => panic!("expected Plain"), + } + } + + #[test] + fn test_header_params_not_in_query_string() { + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "user_id".to_string(), + MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + parameters.insert( + "X-Custom-Header".to_string(), + MethodParameter { + location: Some("header".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "limit".to_string(), + MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "users/{user_id}".to_string(), + parameters, + parameter_order: vec!["user_id".to_string()], + ..Default::default() + }; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = + r#"{"user_id": "123", "X-Custom-Header": "my-value", "limit": "10"}"#; + let input = + parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[], &[]).unwrap(); + + // Header param should be in header_params + assert_eq!(input.header_params.len(), 1); + assert_eq!(input.header_params[0].0, "X-Custom-Header"); + assert_eq!(input.header_params[0].1, "my-value"); + + // Header param should NOT be in query_params + assert!( + !input + .query_params + .iter() + .any(|(k, _)| k == "X-Custom-Header"), + "Header param should not appear in query_params" + ); + + // Query param should still be in query_params + assert!( + input.query_params.iter().any(|(k, _)| k == "limit"), + "Query param should appear in query_params" + ); + } + + #[tokio::test] + async fn test_header_params_sent_as_http_headers() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "users".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/users".to_string(), + body: None, + query_params: Vec::new(), + header_params: vec![( + "X-Custom-Header".to_string(), + "header-value".to_string(), + )], + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("X-Custom-Header") + .map(|v| v.to_str().unwrap()), + Some("header-value"), + "Header params should be sent as HTTP headers" + ); + assert_eq!( + built.headers().get("Accept").map(|v| v.to_str().unwrap()), + Some("application/json"), + "Default Accept prefers JSON for content negotiation" + ); + } + + #[tokio::test] + async fn test_default_accept_skipped_when_accept_in_params() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "users".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/users".to_string(), + body: None, + query_params: Vec::new(), + header_params: vec![("Accept".to_string(), "application/xml".to_string())], + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built.headers().get("Accept").map(|v| v.to_str().unwrap()), + Some("application/xml"), + "Explicit Accept in header params should not be overridden" + ); + } + + #[tokio::test] + async fn test_explicit_anonymous_endpoint_skips_auth() { + // `security: []` on an operation means "this endpoint is explicitly + // unauthenticated" — the executor must not attach credentials even + // when a credential-bearing provider is configured. Regression for + // the leaf/Any/All path: only RoutingAuthProvider honored this + // before; now the executor short-circuits universally. + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "public/ping".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/public/ping".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + // A bare bearer leaf — would normally attach Authorization. + let provider: crate::auth::DynAuthProvider = std::sync::Arc::new( + crate::auth::BearerAuthProvider::new( + "bearerAuth", + crate::auth::AuthCredentialSource::literal("tok"), + ), + ); + + let request = build_http_request( + &client, + &method, + &input, + &provider, + &EndpointAuthMetadata::explicit_anonymous(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get(reqwest::header::AUTHORIZATION).is_none(), + "security: [] must opt out of auth even with a bearer provider" + ); + } + + #[test] + fn test_coerce_body_param_value_scalar_types() { + // CLI flags arrive as Value::String; coerce them per the schema's type. + assert_eq!( + coerce_body_param_value(&Value::String("42".into()), Some("integer")).unwrap(), + json!(42) + ); + assert_eq!( + coerce_body_param_value(&Value::String("2.5".into()), Some("number")).unwrap(), + json!(2.5) + ); + assert_eq!( + coerce_body_param_value(&Value::String("true".into()), Some("boolean")).unwrap(), + Value::Bool(true) + ); + assert_eq!( + coerce_body_param_value(&Value::String("false".into()), Some("boolean")).unwrap(), + Value::Bool(false) + ); + // String type passes through unchanged. + assert_eq!( + coerce_body_param_value(&Value::String("hello".into()), Some("string")).unwrap(), + json!("hello") + ); + // Already-typed values from `--params` JSON pass through. + assert_eq!( + coerce_body_param_value(&json!(99), Some("integer")).unwrap(), + json!(99) + ); + } + + #[test] + fn test_coerce_body_param_value_nested_decodes_json() { + // Object/array body fields accept a JSON string from the CLI flag. + let arr = coerce_body_param_value( + &Value::String(r#"["a","b"]"#.into()), + Some("array"), + ) + .unwrap(); + assert_eq!(arr, json!(["a", "b"])); + + let obj = coerce_body_param_value( + &Value::String(r#"{"city":"SF"}"#.into()), + Some("object"), + ) + .unwrap(); + assert_eq!(obj, json!({ "city": "SF" })); + } + + #[test] + fn test_coerce_body_param_value_object_rejects_non_object_json() { + // Object-shorthand flag must receive a JSON object — arrays, scalars, + // and null are rejected with a clear validation error, mirroring the + // GraphQL `coerce_graphql_value` guard. + for bad in [ + (r#"[1,2,3]"#, "array"), + (r#""hi""#, "string"), + ("42", "number"), + ("true", "boolean"), + ("null", "null"), + ] { + let err = coerce_body_param_value(&Value::String(bad.0.into()), Some("object")) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!( + msg.contains("must be a JSON object") && msg.contains(bad.1), + "expected 'must be a JSON object, got {}' for {}, got: {msg}", + bad.1, + bad.0, + ), + other => panic!("expected Validation error for {}, got {other:?}", bad.0), + } + } + + // Malformed JSON (not a JSON literal at all) falls through to the + // shape check and reports "got string" — consistent with the + // already-decoded `"hi"` case from collect_params_from_flags. + let err = + coerce_body_param_value(&Value::String("{not json}".into()), Some("object")).unwrap_err(); + match err { + CliError::Validation(msg) => assert!( + msg.contains("must be a JSON object") && msg.contains("string"), + "expected 'must be a JSON object, got string' for malformed JSON, got: {msg}" + ), + _ => panic!("expected Validation error for malformed JSON"), + } + + // Pre-parsed values (collect_params_from_flags eagerly JSON-decodes + // object-typed params for deepObject query handling) must also be + // shape-validated — the function must not short-circuit on non-String. + for (pre_parsed, kind) in [ + (json!([1, 2, 3]), "array"), + (json!(42), "number"), + (json!(true), "boolean"), + (Value::Null, "null"), + ] { + let err = coerce_body_param_value(&pre_parsed, Some("object")).unwrap_err(); + match err { + CliError::Validation(msg) => assert!( + msg.contains("must be a JSON object") && msg.contains(kind), + "expected 'must be a JSON object, got {kind}' for pre-parsed {pre_parsed}: {msg}" + ), + other => panic!("expected Validation error for pre-parsed {pre_parsed}, got {other:?}"), + } + } + } + + #[test] + fn test_coerce_body_param_value_rejects_bad_input() { + let err = coerce_body_param_value( + &Value::String("not-an-int".into()), + Some("integer"), + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("Invalid integer")), + _ => panic!("Expected Validation error"), + } + + let err = coerce_body_param_value( + &Value::String("yes".into()), + Some("boolean"), + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => assert!(msg.contains("Invalid boolean")), + _ => panic!("Expected Validation error"), + } + } + + #[test] + fn test_body_params_merge_into_body_via_params_json() { + // `--params` is the JSON-blob fallback that mirrors per-flag values; + // body-located params should land in the JSON body, not the query string. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "count".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("integer".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "Acme", "count": "3"}"#; + let input = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[], &[]) + .unwrap(); + + // Body must contain both fields, with `count` coerced to a JSON integer. + let body = input.body.expect("body should be populated from body params"); + assert_eq!(body, json!({ "name": "Acme", "count": 3 })); + + // Body fields must NOT bleed into the query string or headers. + assert!(input.query_params.is_empty(), "no query params expected"); + assert!(input.header_params.is_empty(), "no header params expected"); + } + + #[test] + fn test_json_flag_overrides_body_field_flags() { + // BREAKING (JFL-1.2): Mixing `--json` with per-field body flags is now + // a validation error. Previously `--json` won on overlapping keys; now + // the user must pick one mode or the other so intent is unambiguous. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "description".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "from-flag", "description": "kept-from-flag"}"#; + let body_json = r#"{"name": "from-json"}"#; + let err = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + &[], + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--json"), + "error must mention --json: {msg}" + ); + assert!( + msg.contains("--name") || msg.contains("--description"), + "error must name a conflicting per-field flag: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_required_body_field_missing_mentions_flag_json_and_params() { + // A required body field that isn't supplied at all should produce a + // validation error that names the per-field flag, --json, and + // --params, so the user knows every way to fix it. The previous + // message only mentioned --params, which was misleading once + // per-field body flags shipped. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("'name'"), "error names the missing field: {msg}"); + assert!(msg.contains("--name"), "error names the per-field flag: {msg}"); + assert!(msg.contains("--json"), "error names --json for body fields: {msg}"); + assert!(msg.contains("--params"), "error names --params: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_required_param_missing_uses_flag_name_override() { + // When a parameter has `flag_name_override` set (e.g. synthetic + // idempotency-key flags inject the wire name verbatim), the error + // message must suggest THAT flag — not a kebab of the wire name. + // Otherwise the suggestion points at a flag the user can't pass. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "Idempotency-Key".to_string(), + MethodParameter { + location: Some("header".to_string()), + param_type: Some("string".to_string()), + required: true, + flag_name_override: Some("idempotency-key".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--idempotency-key"), + "error must point at the actual flag name from flag_name_override: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_non_object_json_replaces_body_and_drops_per_field_flags() { + // BREAKING (JFL-1.2): combining `--json` with per-field body flags is + // now a validation error regardless of the JSON's top-level shape. + // Previously a non-object `--json` would silently drop the per-field + // flag values; now the user is told to pick one mode. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + // `--name` is supplied via params; `--json` is a bare array. + let params_json = r#"{"name": "from-flag-loses"}"#; + let body_json = r#"[1, 2, 3]"#; + let err = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + &[], + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--json"), "error must mention --json: {msg}"); + assert!(msg.contains("--name"), "error must name the per-field flag: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_required_non_body_param_missing_omits_json_hint() { + // The --json hint is body-specific. A missing required query/path/ + // header param should NOT suggest --json — it would mislead the + // user into thinking the body matters here. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "limit".to_string(), + MethodParameter { + location: Some("query".to_string()), + param_type: Some("integer".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--limit"), "error names the per-field flag: {msg}"); + assert!(msg.contains("--params"), "error names --params: {msg}"); + assert!(!msg.contains("--json"), "non-body error should not mention --json: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_missing_body_param_hint_preserves_dot_notation() { + // Gap 1a: For a body param with dot-notation (e.g. `address.street`), + // the missing-required-param error must suggest `--address.street` + // (dots preserved via `to_kebab_flag`), NOT `--address-street`. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "address.street".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "contacts".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--address.street"), + "hint must preserve dots for body params: {msg}", + ); + assert!( + !msg.contains("--address-street"), + "hint must NOT kebab-ify dots to hyphens: {msg}", + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_missing_param_hint_uses_builtin_collision_suffix() { + // Gap 1b: When a required param's flag name collides with a builtin + // (e.g. a param named `format`), the hint must suggest + // `--format-param` (the actually registered flag), NOT `--format`. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "format".to_string(), + MethodParameter { + location: Some("query".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "reports".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("--format-param"), + "hint must use the -param suffixed flag for builtin collisions: {msg}", + ); + // Verify it does NOT suggest bare `--format ` (with trailing + // space to avoid matching `--format-param`). + assert!( + !msg.contains("--format ") && !msg.contains("--format,"), + "hint must NOT suggest the bare builtin flag: {msg}", + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_per_field_body_flags_path_runs_schema_validation() { + // Schema validation must run regardless of whether the body was + // built from --json or from per-field flags. The previous version + // only validated on the --json path, letting flag-only bodies skip + // schema checks even though clap-typed strings are more likely to + // produce shape mismatches than hand-written JSON. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + // Schema declares `name` as an integer — a string value from the + // per-field flag should be rejected by the schema validator. + let mut schema_props = std::collections::HashMap::new(); + schema_props.insert( + "name".to_string(), + crate::openapi::discovery::JsonSchemaProperty { + prop_type: Some("integer".to_string()), + ..Default::default() + }, + ); + let mut schemas = std::collections::HashMap::new(); + schemas.insert( + "ThingRequest".to_string(), + crate::openapi::discovery::JsonSchema { + schema_type: Some("object".to_string()), + properties: schema_props, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + request: Some(crate::openapi::discovery::SchemaRef { + schema_ref: Some("ThingRequest".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + schemas, + ..Default::default() + }; + + let params_json = r#"{"name": "not-an-integer"}"#; + let err = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!( + msg.contains("schema validation"), + "schema validator should fire on flag-only body: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_json_plus_body_flag_returns_validation_error() { + // JFL-1.2: --json and per-field body flags are mutually exclusive. + // The error must name both --json and the conflicting flag so the + // user can immediately see which inputs are fighting. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "from-flag"}"#; + let body_json = r#"{"name": "from-json"}"#; + let err = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + Some(body_json), + false, + None, + &[], + &[], + ) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--json"), "error must mention --json: {msg}"); + assert!(msg.contains("--name"), "error must name the per-field flag: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_object_shorthand_plus_leaf_flag_returns_validation_error() { + // JFL-1.2: `--name` (object shorthand) and `--name.first` (dot-notation + // leaf) target the same field. Mixing both is a validation error. + let mut parameters = std::collections::HashMap::new(); + // Object-level shorthand flag (param_type=="object") emitted by parser. + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("object".to_string()), + ..Default::default() + }, + ); + // Leaf flag for the same field via dot-notation. + parameters.insert( + "name.first".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "people".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "{\"last\":\"Lincoln\"}", "name.first": "Abraham"}"#; + let err = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("--name"), "error must mention --name: {msg}"); + assert!( + msg.contains("--name.first"), + "error must mention --name.first: {msg}" + ); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_object_shorthand_alone_parses_and_sets_nested() { + // JFL-1.2: passing the object-shorthand flag alone JSON-parses the + // string and lands the resulting object at the parent key. This is + // the user-facing alternative to `--name.first X --name.last Y`. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("object".to_string()), + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "people".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "{\"first\":\"Abraham\",\"last\":\"Lincoln\"}"}"#; + let input = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[], &[]) + .unwrap(); + let body = input.body.expect("body should be populated"); + assert_eq!(body, json!({ "name": { "first": "Abraham", "last": "Lincoln" } })); + } + + #[test] + fn test_object_shorthand_satisfies_required_leaf() { + // Spec marks `name.first` required and `name` itself as the shorthand + // umbrella. User provides the data via `--name '{"first":"x"}'` only. + // The required-leaf check must not fire — the value lives in the + // shorthand payload, not as a `name.first` flag entry. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("object".to_string()), + required: false, + ..Default::default() + }, + ); + parameters.insert( + "name.first".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "people".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let params_json = r#"{"name": "{\"first\":\"Abraham\"}"}"#; + let input = parse_and_validate_inputs(&doc, &method, Some(params_json), None, false, None, &[], &[]) + .expect("required leaf satisfied by ancestor shorthand should pass"); + let body = input.body.expect("body should be populated"); + assert_eq!(body, json!({ "name": { "first": "Abraham" } })); + } + + #[test] + fn test_required_leaf_still_reported_when_no_ancestor_shorthand() { + // Sanity check: with the same shape as above but no shorthand value + // supplied, the required-leaf check still fires. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "name".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("object".to_string()), + required: false, + ..Default::default() + }, + ); + parameters.insert( + "name.first".to_string(), + MethodParameter { + location: Some("body".to_string()), + param_type: Some("string".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "people".to_string(), + parameters, + ..Default::default() + }; + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]) + .unwrap_err(); + match err { + CliError::Validation(msg) => { + assert!(msg.contains("name.first"), "error should name leaf: {msg}"); + } + other => panic!("expected Validation error, got {other:?}"), + } + } + + #[test] + fn test_pagination_config_default() { + let config = PaginationConfig::default(); + assert!(!config.page_all); + assert_eq!(config.page_limit, 10); + assert_eq!(config.page_delay_ms, 100); + } + + #[test] + fn test_mime_to_extension_more_types() { + assert_eq!(mime_to_extension("text/plain"), "txt"); + assert_eq!(mime_to_extension("text/csv"), "csv"); + assert_eq!(mime_to_extension("application/zip"), "zip"); + assert_eq!(mime_to_extension("application/xml"), "xml"); + assert_eq!(mime_to_extension("text/html"), "html"); + assert_eq!(mime_to_extension("application/json"), "bin"); // Default for unknown specific json types if not scripts + assert_eq!( + mime_to_extension("application/vnd.google-apps.script"), + "json" + ); + assert_eq!( + mime_to_extension("application/vnd.google-apps.presentation"), + "pptx" + ); + } + + #[test] + fn test_validate_body_valid() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + let body = json!({ "name": "My File" }); + assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + } + + #[test] + fn test_validate_body_accepts_null_on_nullable_property() { + // A property whose schema declares `nullable: true` must accept JSON + // null without raising "Expected type 'string', found null". + let mut properties = HashMap::new(); + properties.insert( + "userId".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + nullable: true, + ..Default::default() + }, + ); + let schemas = HashMap::from([( + "Event".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "userId": null }); + assert!( + validate_body_against_schema(&body, "Event", &doc).is_ok(), + "JSON null on a nullable: true property must validate", + ); + } + + #[test] + fn test_validate_body_rejects_null_on_non_nullable_property() { + // Regression guard: a property with no `nullable` flag must still + // reject JSON null. Keeps the validator strict outside the explicit + // nullable opt-in. + let mut properties = HashMap::new(); + properties.insert( + "code".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let schemas = HashMap::from([( + "Item".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "code": null }); + let result = validate_body_against_schema(&body, "Item", &doc); + assert!(result.is_err(), "null on non-nullable property must still be rejected"); + } + + #[test] + fn test_validate_body_open_schema_allows_any_properties() { + // A schema with type=object but no properties defined is an open schema: + // any properties are allowed (JSON Schema default). + let schemas = HashMap::from([( + "Body".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::new(), + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "name": "foo", "count": 3, "nested": {"x": 1} }); + assert!( + validate_body_against_schema(&body, "Body", &doc).is_ok(), + "open object schema should accept any properties" + ); + } + + #[test] + fn test_validate_body_unknown_field() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + let body = json!({ "name": "My File", "invalidField": 123 }); + let result = validate_body_against_schema(&body, "File", &doc); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Unknown property")); + } + + #[test] + fn test_validate_body_deep_validation() { + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + properties.insert( + "status".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + enum_values: Some(vec!["ACTIVE".to_string(), "INACTIVE".to_string()]), + ..Default::default() + }, + ); + properties.insert( + "count".to_string(), + JsonSchemaProperty { + prop_type: Some("integer".to_string()), + ..Default::default() + }, + ); + properties.insert( + "tags".to_string(), + JsonSchemaProperty { + prop_type: Some("array".to_string()), + items: Some(Box::new(JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + })), + ..Default::default() + }, + ); + properties.insert( + "parent".to_string(), + JsonSchemaProperty { + schema_ref: Some("Parent".to_string()), + ..Default::default() + }, + ); + + let mut parent_props = HashMap::new(); + parent_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + + let mut schemas = HashMap::new(); + schemas.insert( + "File".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + required: vec!["name".to_string(), "status".to_string()], + properties, + ..Default::default() + }, + ); + schemas.insert( + "Parent".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: parent_props, + ..Default::default() + }, + ); + + let doc = RestDescription { + schemas, + ..Default::default() + }; + + // Valid Request + let body = json!({ + "name": "My File", + "status": "ACTIVE", + "count": 10, + "tags": ["one", "two"], + "parent": { "id": "123" } + }); + assert!(validate_body_against_schema(&body, "File", &doc).is_ok()); + + // Missing Required Field + let body_missing = json!({ "name": "My File" }); + let err = validate_body_against_schema(&body_missing, "File", &doc).unwrap_err(); + assert!(err + .to_string() + .contains("Missing required property 'status'")); + + // Invalid Enum Value + let body_bad_enum = json!({ "name": "My File", "status": "UNKNOWN" }); + let err = validate_body_against_schema(&body_bad_enum, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("not a valid enum member")); + + // Invalid Type + let body_bad_type = json!({ "name": "My File", "status": "ACTIVE", "count": "10" }); + let err = validate_body_against_schema(&body_bad_type, "File", &doc).unwrap_err(); + assert!(err + .to_string() + .contains("Expected type 'integer', found string")); + + // Deep Schema Reference Validation Failure + let body_bad_ref = json!({ + "name": "My File", + "status": "ACTIVE", + "parent": { "invalidField": "123" } + }); + let err = validate_body_against_schema(&body_bad_ref, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("Unknown property")); + + // Expected Object Type Failure + let body_not_object = json!([]); + let err = validate_body_against_schema(&body_not_object, "File", &doc).unwrap_err(); + assert!(err.to_string().contains("Expected object")); + } + + #[test] + fn test_validate_body_accepts_null_on_3_0_nullable_branch() { + // Regression: a composition branch in the 3.0 idiom + // (`{nullable: true}` with no concrete type) or the 3.1 array + // form (`{type: ['null']}`) lowers to a `JsonSchemaProperty` + // with `prop_type: None, nullable: true`. The parser's + // `is_null_sentinel` already recognizes both; `has_null_branch` + // must mirror that or else `--field null` on these spec shapes + // gets through the flag layer but fails body validation. + // Caught by Devin's review on PR #124. + let mut properties = HashMap::new(); + properties.insert( + "authorId".to_string(), + JsonSchemaProperty { + // Wrapper property has an explicit `prop_type` so the + // validator's type-matching step would run and reject + // null without the short-circuit. This is the precise + // failure mode the missing recognition would create. + prop_type: Some("string".to_string()), + nullable: false, + any_of: vec![ + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + // Lowered form of `{nullable: true}` (3.0) or + // `{type: ['null']}` (3.1 array). + JsonSchemaProperty { + prop_type: None, + nullable: true, + ..Default::default() + }, + ], + ..Default::default() + }, + ); + let schemas = HashMap::from([( + "Msg".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "authorId": null }); + assert!( + validate_body_against_schema(&body, "Msg", &doc).is_ok(), + "null on a property whose composition has a {{nullable:true}}-style branch must validate", + ); + } + + #[test] + fn test_validate_body_accepts_null_on_nullable_union_via_any_of() { + // ADR-0005: a property whose schema is `anyOf: [{type: string}, + // {type: 'null'}]` must accept JSON null without raising + // "Expected type 'string', found null". The intrinsic + // `nullable` flag is false here — null-ness lives in the + // composition. + let mut properties = HashMap::new(); + properties.insert( + "authorId".to_string(), + JsonSchemaProperty { + prop_type: None, + nullable: false, + any_of: vec![ + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + JsonSchemaProperty { + prop_type: Some("null".to_string()), + ..Default::default() + }, + ], + ..Default::default() + }, + ); + let schemas = HashMap::from([( + "Msg".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "authorId": null }); + assert!( + validate_body_against_schema(&body, "Msg", &doc).is_ok(), + "null on nullable-union must validate via any_of null branch", + ); + } + + #[test] + fn test_validate_body_root_level_all_of_enters_object_branch() { + // ADR-0004: a top-level schema with no `type:` declared but + // `all_of:` populated must still enter the object validation + // branch and accept the merged properties from the branches. + // Without this, the validator would silently skip the body. + let base_properties = HashMap::from([( + "subject".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]); + let overlay_branch = JsonSchemaProperty { + prop_type: Some("object".to_string()), + properties: HashMap::from([( + "body".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }; + let schemas = HashMap::from([ + ( + "Base".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + required: vec!["subject".to_string()], + properties: base_properties, + ..Default::default() + }, + ), + ( + "MsgRequest".to_string(), + JsonSchema { + schema_type: None, + all_of: vec![ + JsonSchemaProperty { + schema_ref: Some("Base".to_string()), + ..Default::default() + }, + overlay_branch, + ], + ..Default::default() + }, + ), + ]); + let doc = RestDescription { schemas, ..Default::default() }; + // Valid: both merged props present. + let body = json!({ "subject": "hi", "body": "world" }); + assert!( + validate_body_against_schema(&body, "MsgRequest", &doc).is_ok(), + "all_of-rooted body should accept merged fields", + ); + // Invalid: unknown field surfaces (proves the merged property + // set is being consulted, not skipped). + let bad = json!({ "subject": "hi", "body": "world", "stray": 1 }); + let err = validate_body_against_schema(&bad, "MsgRequest", &doc).unwrap_err(); + assert!(err.to_string().contains("Unknown property")); + } + + #[test] + fn test_validate_body_object_property_with_all_of_overlay() { + // ADR-0004 nested case: an object property whose schema declares + // `all_of: [...]` should validate against the merged property + // set, not the bare `properties` map (which is empty for the + // synthetic JsonSchemaProperty). + let base_props = HashMap::from([( + "url".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]); + // The body's `attachment` property is `type: object` with an + // allOf overlay that brings in `url` from a $ref base and adds + // `checksum` inline. + let attachment_prop = JsonSchemaProperty { + prop_type: Some("object".to_string()), + all_of: vec![ + JsonSchemaProperty { + schema_ref: Some("AttachmentBase".to_string()), + ..Default::default() + }, + JsonSchemaProperty { + prop_type: Some("object".to_string()), + properties: HashMap::from([( + "checksum".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + ], + ..Default::default() + }; + let schemas = HashMap::from([ + ( + "AttachmentBase".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: base_props, + ..Default::default() + }, + ), + ( + "Msg".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::from([("attachment".to_string(), attachment_prop)]), + ..Default::default() + }, + ), + ]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ + "attachment": { "url": "https://x.example", "checksum": "abc" } + }); + assert!( + validate_body_against_schema(&body, "Msg", &doc).is_ok(), + "merged nested allOf properties should validate", + ); + let bad = json!({ "attachment": { "stray": 1 } }); + let err = validate_body_against_schema(&bad, "Msg", &doc).unwrap_err(); + assert!(err.to_string().contains("Unknown property")); + } + + #[test] + fn test_validate_body_object_property_with_typeless_all_of_overlay() { + // Regression: a property declared as bare `allOf: [...]` (no + // redundant `type: object` on the wrapper) lowers to a + // `JsonSchemaProperty { prop_type: None, all_of: [...] }`. The + // parser-side flattener correctly enters object recursion on + // either `prop_type == "object"` OR a non-empty `all_of`; the + // validator's condition must mirror that. Without the fix, an + // unknown field inside an allOf-typed object property would + // silently pass validation. Caught by Devin's review on PR #124. + let base_props = HashMap::from([( + "url".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]); + let attachment_prop = JsonSchemaProperty { + // Note: no `prop_type` here — the precise gap the fix addresses. + prop_type: None, + all_of: vec![ + JsonSchemaProperty { + schema_ref: Some("AttachmentBase".to_string()), + ..Default::default() + }, + JsonSchemaProperty { + properties: HashMap::from([( + "checksum".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + ], + ..Default::default() + }; + let schemas = HashMap::from([ + ( + "AttachmentBase".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: base_props, + ..Default::default() + }, + ), + ( + "Msg".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::from([("attachment".to_string(), attachment_prop)]), + ..Default::default() + }, + ), + ]); + let doc = RestDescription { schemas, ..Default::default() }; + // Valid: both merged fields present, no unknowns. + let body = json!({ + "attachment": { "url": "https://x.example", "checksum": "abc" } + }); + assert!( + validate_body_against_schema(&body, "Msg", &doc).is_ok(), + "typeless allOf-property should validate against merged property set", + ); + // Active: an unknown field should be REJECTED. Before the fix, + // validation was skipped entirely for this shape — the bug + // surfaces precisely here. + let bad = json!({ "attachment": { "stray": 1 } }); + let err = validate_body_against_schema(&bad, "Msg", &doc).unwrap_err(); + assert!( + err.to_string().contains("Unknown property"), + "typeless allOf-property should reject unknown fields: got err {err}", + ); + // Active: a non-object value (e.g. string) should also be + // rejected. A typeless allOf-property has no `prop_type` to + // trigger step-2's type mismatch, so without the else-branch + // a non-object would silently pass. Caught by Devin's review. + let wrong_shape = json!({ "attachment": "not-an-object" }); + let err = validate_body_against_schema(&wrong_shape, "Msg", &doc).unwrap_err(); + assert!( + err.to_string().contains("Expected object"), + "typeless allOf-property should reject non-object values: got err {err}", + ); + } + + #[test] + fn test_validate_body_property_all_of_enforces_ref_resolved_required() { + // `merge_property_all_of` previously computed the required set + // from $ref-resolved branches and then discarded it, so a body + // like `--json '{"attachment": {}}'` against a property whose + // allOf includes a $ref to a schema with `required: [url]` + // passed silently. Now the required set is threaded through to + // `validate_properties`, so the missing field surfaces. + // (Inline-branch required is still a documented IR gap; only + // $ref-resolved required is enforced at the property level.) + let base_props = HashMap::from([( + "url".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]); + let attachment_prop = JsonSchemaProperty { + prop_type: None, + all_of: vec![JsonSchemaProperty { + schema_ref: Some("AttachmentBase".to_string()), + ..Default::default() + }], + ..Default::default() + }; + let schemas = HashMap::from([ + ( + "AttachmentBase".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + required: vec!["url".to_string()], + properties: base_props, + ..Default::default() + }, + ), + ( + "Msg".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::from([("attachment".to_string(), attachment_prop)]), + ..Default::default() + }, + ), + ]); + let doc = RestDescription { schemas, ..Default::default() }; + + // Happy path: required `url` present. + let ok = json!({ "attachment": { "url": "https://x.example" } }); + assert!( + validate_body_against_schema(&ok, "Msg", &doc).is_ok(), + "well-formed body should pass", + ); + + // Failure path: empty attachment object. Without the fix, this + // passed silently; now the missing-required check fires. + let missing = json!({ "attachment": {} }); + let err = validate_body_against_schema(&missing, "Msg", &doc).unwrap_err(); + assert!( + err.to_string().contains("Missing required property 'url'"), + "$ref-resolved required at property level must be enforced: got err {err}", + ); + } + + #[test] + fn test_validate_body_ref_to_nullable_schema_accepts_null() { + // A property that `$ref`s a nullable object schema must accept + // null. `validate_property` delegates `$ref` properties to + // `validate_value` before the property-level null check, so + // `validate_value` itself must honor `schema.nullable`. + let schemas = HashMap::from([ + ( + "NullableAddress".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + nullable: true, + properties: HashMap::from([( + "street".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + ), + ( + "User".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::from([( + "address".to_string(), + JsonSchemaProperty { + schema_ref: Some("NullableAddress".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + ), + ]); + let doc = RestDescription { schemas, ..Default::default() }; + let body = json!({ "address": null }); + assert!( + validate_body_against_schema(&body, "User", &doc).is_ok(), + "null on a $ref to a nullable schema must be accepted", + ); + } + + #[tokio::test] + async fn test_build_multipart_body() { + let metadata = Some(json!({ "name": "test.txt", "mimeType": "text/plain" })); + let content = b"Hello world"; + + let (body, content_type) = build_multipart_body(&metadata, content, "text/plain").unwrap(); + + // Check content type has boundary + assert!(content_type.starts_with("multipart/related; boundary=")); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + + let body_str = String::from_utf8(body).unwrap(); + + // Verify structure + assert!(body_str.contains(boundary)); + assert!(body_str.contains("Content-Type: application/json")); + assert!(body_str.contains("{\"mimeType\":\"text/plain\",\"name\":\"test.txt\"}")); + assert!(body_str.contains("Content-Type: text/plain")); + assert!(body_str.contains("Hello world")); + } + + #[tokio::test] + async fn test_build_multipart_body_no_metadata() { + let metadata = None; + let content = b"Binary data"; + + let (body, content_type) = + build_multipart_body(&metadata, content, "application/octet-stream").unwrap(); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + let body_str = String::from_utf8(body).unwrap(); + + assert!(body_str.contains(boundary)); + assert!(body_str.contains("application/octet-stream")); + assert!(body_str.contains("Binary data")); + } + + #[test] + fn test_resolve_upload_mime_explicit_flag() { + let metadata = Some(json!({ "mimeType": "image/png" })); + let mime = resolve_upload_mime(Some("text/markdown"), Some("file.txt"), &metadata); + assert_eq!(mime, "text/markdown", "explicit flag takes top priority"); + } + + #[test] + fn test_resolve_upload_mime_extension_beats_metadata() { + let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" })); + let mime = resolve_upload_mime(None, Some("notes.md"), &metadata); + assert_eq!( + mime, "text/markdown", + "extension inference ranks above metadata mimeType" + ); + } + + #[test] + fn test_resolve_upload_mime_metadata_fallback_for_unknown_extension() { + let metadata = Some(json!({ "mimeType": "text/plain" })); + let mime = resolve_upload_mime(None, Some("file.unknown"), &metadata); + assert_eq!( + mime, "text/plain", + "metadata mimeType is used when extension is unrecognized" + ); + } + + #[test] + fn test_resolve_upload_mime_extension_when_no_metadata() { + let mime = resolve_upload_mime(None, Some("notes.md"), &None); + assert_eq!(mime, "text/markdown"); + + let mime = resolve_upload_mime(None, Some("page.html"), &None); + assert_eq!(mime, "text/html"); + + let mime = resolve_upload_mime(None, Some("data.csv"), &None); + assert_eq!(mime, "text/csv"); + } + + #[test] + fn test_resolve_upload_mime_fallback() { + let mime = resolve_upload_mime(None, Some("file.unknown"), &None); + assert_eq!(mime, "application/octet-stream"); + } + + #[test] + fn test_resolve_upload_mime_explicit_enables_import_conversion() { + let metadata = Some(json!({ "mimeType": "application/vnd.google-apps.document" })); + let mime = resolve_upload_mime(Some("text/markdown"), Some("impact.md"), &metadata); + assert_eq!( + mime, "text/markdown", + "--upload-content-type overrides metadata for media part" + ); + } + + #[test] + fn test_build_multipart_bytes_with_metadata() { + let metadata = Some(json!({ "threadId": "thread-123" })); + let data = b"From: test@example.com\r\nSubject: Test\r\n\r\nBody"; + let (_, content_type, content_length) = + build_multipart_bytes(&metadata, data, "message/rfc822").unwrap(); + + assert!( + content_type.starts_with("multipart/related; boundary=fern_boundary_"), + "content_type should be multipart/related: {content_type}", + ); + // Content-length should cover: preamble + data + postamble + assert!( + content_length > data.len() as u64, + "content_length should exceed raw data size: {content_length}", + ); + } + + #[test] + fn test_build_multipart_bytes_without_metadata() { + let (_, content_type, content_length) = + build_multipart_bytes(&None, b"test body", "message/rfc822").unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + assert!(content_length > 0); + } + + #[tokio::test] + async fn test_build_multipart_stream_content_length() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("small.txt"); + let file_content = b"Hello stream"; + std::fs::write(&file_path, file_content).unwrap(); + + let metadata_value = json!({ "name": "small.txt" }); + let metadata = Some(metadata_value.clone()); + let file_size = file_content.len() as u64; + + let (_body, content_type, declared_len) = build_multipart_stream( + &metadata, + file_path.to_str().unwrap(), + file_size, + "text/plain", + ) + .unwrap(); + + assert!(content_type.starts_with("multipart/related; boundary=")); + let boundary = content_type.split("boundary=").nth(1).unwrap(); + + // Manually compute expected content length: + // preamble = "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{json}\r\n--{boundary}\r\nContent-Type: text/plain\r\n\r\n" + // postamble = "\r\n--{boundary}--\r\n" + let metadata_json = serde_json::to_string(&metadata_value).unwrap(); + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{metadata_json}\r\n\ + --{boundary}\r\nContent-Type: text/plain\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + let expected = preamble.len() as u64 + file_size + postamble.len() as u64; + assert_eq!( + declared_len, expected, + "declared Content-Length must match expected preamble + file + postamble" + ); + } + + #[tokio::test] + async fn test_build_multipart_stream_large_file() { + let dir = tempfile::tempdir().unwrap(); + let file_path = dir.path().join("large.bin"); + // 256 KB — larger than the default 64 KB ReaderStream chunk size + let data = vec![0xABu8; 256 * 1024]; + std::fs::write(&file_path, &data).unwrap(); + + let metadata = None; + let file_size = data.len() as u64; + + let (_body, _content_type, declared_len) = build_multipart_stream( + &metadata, + file_path.to_str().unwrap(), + file_size, + "application/octet-stream", + ) + .unwrap(); + + // Content-Length must account for the empty-metadata preamble + large file + postamble + assert!( + declared_len > file_size, + "Content-Length ({declared_len}) must be larger than file size ({file_size}) due to multipart framing" + ); + + // Verify exact arithmetic: preamble overhead + file_size + postamble + let boundary = _content_type.split("boundary=").nth(1).unwrap(); + let preamble = format!( + "--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n{{}}\r\n\ + --{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n" + ); + let postamble = format!("\r\n--{boundary}--\r\n"); + let expected = preamble.len() as u64 + file_size + postamble.len() as u64; + assert_eq!( + declared_len, expected, + "Content-Length must match for multi-chunk files" + ); + } + + #[test] + fn test_build_url_basic() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files"); + } + + #[test] + fn test_build_url_override_replaces_spec_base() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + // Use a leading-slash path matching real OpenAPI spec output + let method = RestMethod { + path: "/files".to_string(), + flat_path: Some("/files".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url, "http://localhost:9000/files"); + } + + #[test] + fn test_build_url_override_trailing_slash_normalized() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + // Use a leading-slash path matching real OpenAPI spec output + let method = RestMethod { + path: "/users/me".to_string(), + flat_path: Some("/users/me".to_string()), + ..Default::default() + }; + let params = Map::new(); + + // With trailing slash on override + let (url_with, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000/")).unwrap(); + // Without trailing slash on override + let (url_without, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url_with, url_without); + assert_eq!(url_with, "http://localhost:9000/users/me"); + } + + #[test] + fn test_build_url_override_no_double_slash_with_leading_slash_path() { + // Regression test: OpenAPI paths start with /, override must not produce // + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/users/me".to_string(), + flat_path: Some("/users/me".to_string()), + ..Default::default() + }; + let params = Map::new(); + + let (url, _) = build_url(&doc, &method, ¶ms, false, Some("http://localhost:9000")).unwrap(); + assert_eq!(url, "http://localhost:9000/users/me"); + } + + // ----------------------------------------------------------------------- + // x-fern-base-path + // + // Exhaustive 2x2 matrix over the spec's `x-fern-base-path` value + // (with/without leading slash) and the base URL's trailing slash. The + // wire tests in tests/openapi_fixture_wire.rs exercise the same matrix + // end-to-end through the HTTP stack. + // ----------------------------------------------------------------------- + + fn base_path_doc(base_path: &str) -> RestDescription { + RestDescription { + base_path: Some(base_path.to_string()), + ..Default::default() + } + } + + fn things_method() -> RestMethod { + RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + } + } + + #[test] + fn test_build_url_base_path_leading_slash_x_server_trailing_slash() { + let doc = base_path_doc("/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example/"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_leading_slash_x_server_no_trailing_slash() { + let doc = base_path_doc("/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_no_leading_slash_x_server_trailing_slash() { + let doc = base_path_doc("v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example/"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_no_leading_slash_x_server_no_trailing_slash() { + let doc = base_path_doc("v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_applies_to_spec_root_url() { + // No base_url override, no doc.base_url — base_path applies on top + // of the effective root_url (which is what OpenAPI's `servers[0].url` + // becomes after parsing). + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/api/public".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/api/public/things"); + } + + #[test] + fn test_build_url_base_path_composes_with_per_operation_server() { + // Per-operation `servers[]` override is captured in `method.root_url` + // by the parser. `effective_root_url` returns it (taking precedence + // over the spec-level `doc.root_url`), and `apply_base_path` then + // prepends the base path on top of the per-op server. This test + // pins that composition — without it, a per-op upload-host override + // would silently lose the base path prefix. + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/uploads".to_string(), + flat_path: Some("/uploads".to_string()), + root_url: "https://upload.example.com".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_base_path_per_op_server_with_trailing_slash() { + // Same composition as the test above, but the per-op server URL + // carries a trailing slash — the slash-edge normalization runs at + // the per-op + base_path boundary too, not just at the doc.root_url + // + base_path boundary. + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/uploads".to_string(), + flat_path: Some("/uploads".to_string()), + root_url: "https://upload.example.com/".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_base_path_applies_to_doc_base_url() { + // doc.base_url (set when the spec's server includes a path + // component) is also augmented by base_path. + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/things"); + } + + #[test] + fn test_build_url_base_path_with_trailing_slash_normalized() { + // Authoring quirk: `x-fern-base-path: /v1/` should not produce + // double slashes against the operation path. + let doc = base_path_doc("/v1/"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things"); + } + + #[test] + fn test_build_url_base_path_multi_segment() { + // Multi-segment base paths (e.g. `/api/v1`) are emitted verbatim; + // only the boundary slashes against the server URL and operation + // path are normalized. + let doc = base_path_doc("/api/v1"); + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/api/v1/things"); + } + + #[test] + fn test_build_url_base_path_none_unchanged() { + // When `base_path` is None the URL is identical to the pre-feature + // behavior — this protects existing specs that don't use the + // extension from any drift. + let doc = RestDescription { + base_path: None, + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url( + &doc, + &method, + &Map::new(), + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/things"); + } + + #[test] + fn test_build_url_base_path_preserves_path_substitution() { + // Path parameter substitution still happens against the operation + // path after base_path is prepended. + let doc = base_path_doc("/v1"); + let method = RestMethod { + path: "/things/{thingId}".to_string(), + flat_path: Some("/things/{thingId}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("thingId".to_string(), json!("abc")); + let (url, _) = build_url( + &doc, + &method, + ¶ms, + false, + Some("http://server.example"), + ) + .unwrap(); + assert_eq!(url, "http://server.example/v1/things/abc"); + } + + #[test] + fn test_apply_base_path_helper_handles_edge_cases() { + // None → base returned verbatim. + assert_eq!(apply_base_path("http://x", None), "http://x"); + assert_eq!(apply_base_path("http://x/", None), "http://x/"); + + // Empty / slash-only base_path is a no-op — the helper returns + // the base verbatim and leaves trailing-slash normalization to + // build_url's existing operation-path joining logic. + assert_eq!(apply_base_path("http://x", Some("")), "http://x"); + assert_eq!(apply_base_path("http://x", Some("/")), "http://x"); + assert_eq!(apply_base_path("http://x/", Some("/")), "http://x/"); + } + + /// `x-fern-base-path` with a templated path parameter (e.g. + /// `/{tenant}/v1`) substitutes the placeholder from the operation's + /// parameters at request time, and the consumed parameter is NOT + /// echoed in the query string. Mirrors upstream Fern's behavior of + /// baking the base path into endpoint paths at Definition build + /// time and resolving placeholders uniformly with the rest of the + /// path-parameter renderer. + #[test] + fn test_build_url_base_path_templated_param_substitutes_and_does_not_leak_to_query() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things"); + assert!(qs.is_empty(), "tenant must be consumed by base_path, not leaked as query: {qs:?}"); + } + + /// Multi-placeholder base paths (e.g. `/{region}/{tenant}/v1`) are + /// rendered uniformly; both placeholder params are consumed by the + /// URL path and neither leaks to the query string. + #[test] + fn test_build_url_base_path_multi_templated_params_substitute() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{region}/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("region".to_string(), json!("us-east-1")); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/us-east-1/acme/v1/things"); + assert!(qs.is_empty(), "both placeholders must be consumed: {qs:?}"); + } + + /// A templated base path composes with operation-level path + /// parameters: the base path placeholder and the endpoint path + /// placeholder both substitute, and only non-path params survive + /// as query string entries. + #[test] + fn test_build_url_base_path_templated_with_operation_path_param_and_query() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let mut method_params: HashMap = + HashMap::new(); + method_params.insert( + "tenant".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "id".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + method_params.insert( + "verbose".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("query".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "/things/{id}".to_string(), + flat_path: Some("/things/{id}".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + params.insert("id".to_string(), json!("thing-1")); + params.insert("verbose".to_string(), json!("true")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things/thing-1"); + assert_eq!(qs, vec![("verbose".to_string(), "true".to_string())]); + } + + /// A templated base path composes additively with `--base-url` + /// override, just like a literal base path does. The override + /// supplies the host; the templated base path still applies. + #[test] + fn test_build_url_base_path_templated_param_with_base_url_override() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, Some("https://staging.example.com")).unwrap(); + assert_eq!(url, "https://staging.example.com/acme/v1/things"); + assert!(qs.is_empty()); + } + + /// A param declared as `in: path` on the operation but whose + /// placeholder lives only in `x-fern-base-path` (not in the + /// operation's URL template) must NOT trigger the "path parameter + /// not in URL template" validation error — it's still a path + /// parameter, just one that the base path consumes. This is the + /// most natural customer pattern when their OpenAPI declares a + /// shared prefix param like `{tenant}` at the path-item level. + #[test] + fn test_build_url_base_path_templated_param_declared_as_path_param_does_not_error() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let mut method_params: HashMap = + HashMap::new(); + method_params.insert( + "tenant".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let (url, qs) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/acme/v1/things"); + assert!(qs.is_empty()); + } + + /// When a placeholder in `x-fern-base-path` has no corresponding + /// parameter, the placeholder is left literal in the URL — same + /// fallback behavior as `render_path_template` on endpoint paths. + /// This avoids a hard error for partial fills (e.g. callers that + /// stub the base path) while still making the missing param + /// visible in the outgoing URL. + #[test] + fn test_build_url_base_path_templated_param_missing_value_leaves_placeholder() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/{tenant}/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/things".to_string(), + flat_path: Some("/things".to_string()), + ..Default::default() + }; + let (url, qs) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/{tenant}/v1/things"); + assert!(qs.is_empty()); + } + + /// `doc.base_url` with a *path component* (i.e. the spec's + /// `servers[].url` includes a path) composes with `base_path` — + /// `apply_base_path` doesn't care whether the base is a bare host or + /// host+path; it just joins with one slash. + #[test] + fn test_build_url_base_path_doc_base_url_with_path_component() { + let doc = RestDescription { + base_url: Some("https://api.example.com/v2".to_string()), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = things_method(); + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v2/v1/things"); + } + + /// `base_path` is applied uniformly to the `is_upload` codepath too, + /// not just to the regular path. Currently unreachable for OpenAPI + /// specs (the OpenAPI parser never populates `media_upload`), but + /// pinning the wiring makes the code self-consistent — if a future + /// change ever exposes media uploads to OpenAPI, base_path won't + /// silently be dropped. + #[test] + fn test_build_url_base_path_applies_to_media_upload_branch() { + let doc = RestDescription { + root_url: "https://api.example.com".to_string(), + base_path: Some("/v1".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "/files".to_string(), + flat_path: Some("/files".to_string()), + supports_media_upload: true, + media_upload: Some(crate::openapi::discovery::MediaUpload { + protocols: Some(crate::openapi::discovery::MediaUploadProtocols { + simple: Some(crate::openapi::discovery::MediaUploadProtocol { + path: "/upload/files".to_string(), + ..Default::default() + }), + }), + ..Default::default() + }), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), true, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/upload/files"); + } + + #[test] + fn test_build_url_substitution() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files/{fileId}".to_string(), + flat_path: Some("files/{fileId}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("123")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files/123"); + } + + #[test] + fn test_build_url_query_params() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("q".to_string(), json!("search term")); + + let (url, query) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://api.example.com/files"); + assert_eq!(query, vec![("q".to_string(), "search term".to_string())]); + } + + #[test] + fn test_build_url_repeated_query_param_expands_array() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut method_params = HashMap::new(); + method_params.insert( + "metadataHeaders".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + repeated: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "messages".to_string(), + flat_path: Some("messages".to_string()), + parameters: method_params, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "metadataHeaders".to_string(), + json!(["Subject", "Date", "From"]), + ); + + let (_url, query) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + query, + vec![ + ("metadataHeaders".to_string(), "Subject".to_string()), + ("metadataHeaders".to_string(), "Date".to_string()), + ("metadataHeaders".to_string(), "From".to_string()), + ] + ); + } + + #[test] + fn test_build_url_encodes_path_parameter_chars() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "spreadsheetId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + parameters.insert( + "range".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "spreadsheets/{spreadsheetId}/values/{range}".to_string(), + flat_path: Some("spreadsheets/{spreadsheetId}/values/{range}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("spreadsheetId".to_string(), json!("abc123")); + params.insert("range".to_string(), json!("hash#1!A1:B2")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/spreadsheets/abc123/values/hash%231%21A1%3AB2" + ); + } + + #[test] + fn test_build_url_plus_expansion_preserves_slashes() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert( + "name".to_string(), + json!("projects/p1/locations/us/topics/t1"), + ); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/v1/projects/p1/locations/us/topics/t1" + ); + } + + #[test] + fn test_build_url_plus_expansion_rejects_reserved_chars() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("name".to_string(), json!("projects/p1#frag?x=y")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err.to_string().contains("must not contain '?' or '#'")); + } + + #[test] + fn test_build_url_plus_expansion_rejects_path_traversal() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "name".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/{+name}".to_string(), + flat_path: Some("v1/{+name}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("name".to_string(), json!("projects/../../etc/passwd")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err.to_string().contains("dot-segment")); + } + + #[test] + fn test_build_url_upload_endpoint_substitutes_path_params() { + let doc = RestDescription { + root_url: "https://www.googleapis.com/".to_string(), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "drive/v3/files/{fileId}".to_string(), + flat_path: Some("drive/v3/files/{fileId}".to_string()), + parameters, + media_upload: Some(crate::openapi::discovery::MediaUpload { + protocols: Some(crate::openapi::discovery::MediaUploadProtocols { + simple: Some(crate::openapi::discovery::MediaUploadProtocol { + path: "/upload/drive/v3/files/{fileId}".to_string(), + multipart: Some(true), + }), + }), + ..Default::default() + }), + ..Default::default() + }; + + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("abc/123")); + + let (url, _) = build_url(&doc, &method, ¶ms, true, None).unwrap(); + assert_eq!( + url, + "https://www.googleapis.com/upload/drive/v3/files/abc%2F123" + ); + } + + #[test] + fn test_build_url_does_not_replace_placeholder_like_values() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + path: "v1/{parent}/{child}".to_string(), + flat_path: Some("v1/{parent}/{child}".to_string()), + ..Default::default() + }; + let mut params = Map::new(); + params.insert("parent".to_string(), json!("literal-{child}-value")); + params.insert("child".to_string(), json!("ok")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!( + url, + "https://api.example.com/v1/literal-%7Bchild%7D-value/ok" + ); + } + + #[test] + fn test_build_url_errors_for_path_param_not_in_template() { + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + path: "files".to_string(), + flat_path: Some("files".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("123")); + + let err = build_url(&doc, &method, ¶ms, false, None).unwrap_err(); + assert!(err + .to_string() + .contains("Path parameter 'fileId' was provided but is not present")); + } + + #[test] + fn test_build_url_flatpath_fallback_on_mismatch() { + // Reproduces the Slides presentations.get bug where flatPath uses + // {presentationsId} (plural) but the parameter is presentationId (singular). + let doc = RestDescription { + base_url: Some("https://slides.googleapis.com/".to_string()), + ..Default::default() + }; + let mut parameters = HashMap::new(); + parameters.insert( + "presentationId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + path: "v1/presentations/{+presentationId}".to_string(), + flat_path: Some("v1/presentations/{presentationsId}".to_string()), + parameters, + ..Default::default() + }; + let mut params = Map::new(); + params.insert("presentationId".to_string(), json!("abc123")); + + let (url, _) = build_url(&doc, &method, ¶ms, false, None).unwrap(); + assert_eq!(url, "https://slides.googleapis.com/v1/presentations/abc123"); + } + + #[test] + fn test_serialize_deep_object() { + let value = json!({"status": "active", "date": "2024-01-01"}); + let result = serialize_query_param( + "filter", + &value, + Some(&MethodParameter { + style: Some("deepObject".to_string()), + ..Default::default() + }), + ); + assert!(result.contains(&("filter[status]".to_string(), "active".to_string()))); + assert!(result.contains(&("filter[date]".to_string(), "2024-01-01".to_string()))); + } + + #[test] + fn test_serialize_form_explode_array() { + let value = json!(["a", "b", "c"]); + let result = serialize_query_param( + "tags", + &value, + Some(&MethodParameter { + style: Some("form".to_string()), + explode: Some(true), + ..Default::default() + }), + ); + assert_eq!( + result, + vec![ + ("tags".to_string(), "a".to_string()), + ("tags".to_string(), "b".to_string()), + ("tags".to_string(), "c".to_string()), + ] + ); + } + + #[test] + fn test_serialize_form_no_explode_array() { + let value = json!(["a", "b", "c"]); + let result = serialize_query_param( + "tags", + &value, + Some(&MethodParameter { + style: Some("form".to_string()), + explode: Some(false), + ..Default::default() + }), + ); + assert_eq!(result, vec![("tags".to_string(), "a,b,c".to_string())]); + } + + #[test] + fn test_serialize_default_style_is_form() { + // No style specified -> defaults to form with explode + let value = json!("hello"); + let result = serialize_query_param("q", &value, None); + assert_eq!(result, vec![("q".to_string(), "hello".to_string())]); + } + + fn param_with(style: &str, explode: Option) -> MethodParameter { + MethodParameter { + style: Some(style.to_string()), + explode, + ..Default::default() + } + } + + fn path_param(style: &str, explode: Option) -> MethodParameter { + MethodParameter { + location: Some("path".to_string()), + style: Some(style.to_string()), + explode, + ..Default::default() + } + } + + // ── query-param style tests (from main) ────────────────────────────── + + #[test] + fn test_serialize_space_delimited_array() { + // spaceDelimited joins elements with a literal space; the encoder + // turns that into `%20` on the wire. + let value = json!(["1", "2"]); + let result = serialize_query_param("ids", &value, Some(¶m_with("spaceDelimited", Some(false)))); + assert_eq!(result, vec![("ids".to_string(), "1 2".to_string())]); + } + + #[test] + fn test_serialize_pipe_delimited_array() { + let value = json!(["1", "2"]); + let result = serialize_query_param("ids", &value, Some(¶m_with("pipeDelimited", Some(false)))); + assert_eq!(result, vec![("ids".to_string(), "1|2".to_string())]); + } + + #[test] + fn test_serialize_delimited_scalar_degrades_to_value() { + // A non-array under a delimited style is just the scalar value. + let value = json!("solo"); + let result = serialize_query_param("ids", &value, Some(¶m_with("spaceDelimited", Some(false)))); + assert_eq!(result, vec![("ids".to_string(), "solo".to_string())]); + } + + #[test] + fn test_serialize_form_explode_object() { + // form/object/explode=true: each property becomes its own top-level + // key; the parameter name is dropped. + let value = json!({"role": "admin", "active": "true"}); + let result = serialize_query_param("profile", &value, Some(¶m_with("form", Some(true)))); + assert!(result.contains(&("role".to_string(), "admin".to_string())), "got: {result:?}"); + assert!(result.contains(&("active".to_string(), "true".to_string())), "got: {result:?}"); + assert!( + !result.iter().any(|(k, _)| k == "profile"), + "parameter name must be dropped for exploded object; got: {result:?}" + ); + } + + #[test] + fn test_serialize_form_no_explode_object() { + // form/object/explode=false: comma-joined key,value pairs under the + // single parameter key. + let value = json!({"role": "admin"}); + let result = serialize_query_param("profile", &value, Some(¶m_with("form", Some(false)))); + assert_eq!(result, vec![("profile".to_string(), "role,admin".to_string())]); + } + + #[test] + fn test_encode_query_component_space_is_percent20_not_plus() { + // RFC 3986: a literal space encodes as %20, never the form `+`. + assert_eq!(encode_query_component("a b"), "a%20b"); + } + + #[test] + fn test_encode_query_component_reserved_chars() { + assert_eq!(encode_query_component("a&b=c#d"), "a%26b%3Dc%23d"); + // Brackets (deepObject keys) are percent-encoded. + assert_eq!(encode_query_component("filter[status]"), "filter%5Bstatus%5D"); + // The pipe delimiter encodes to %7C. + assert_eq!(encode_query_component("1|2"), "1%7C2"); + } + + #[test] + fn test_encode_query_component_comma_stays_literal() { + // The comma is the form/no-explode delimiter and must stay literal. + assert_eq!(encode_query_component("1,2"), "1,2"); + } + + #[test] + fn test_encode_query_component_unreserved_untouched() { + assert_eq!(encode_query_component("Aa0-_.~"), "Aa0-_.~"); + } + + #[test] + fn test_append_query_string_first_param_uses_question_mark() { + let pairs = vec![("ids".to_string(), "1 2".to_string())]; + assert_eq!( + append_query_string("https://api.example.com/x", &pairs), + "https://api.example.com/x?ids=1%202" + ); + } + + #[test] + fn test_append_query_string_continues_existing_query() { + let pairs = vec![("b".to_string(), "2".to_string())]; + assert_eq!( + append_query_string("https://api.example.com/x?a=1", &pairs), + "https://api.example.com/x?a=1&b=2" + ); + } + + #[test] + fn test_append_query_string_empty_pairs_is_unchanged() { + assert_eq!( + append_query_string("https://api.example.com/x", &[]), + "https://api.example.com/x" + ); + } + + #[test] + fn test_append_query_string_joins_multiple_with_ampersand() { + let pairs = vec![ + ("role".to_string(), "admin".to_string()), + ("active".to_string(), "true".to_string()), + ]; + assert_eq!( + append_query_string("https://api.example.com/x", &pairs), + "https://api.example.com/x?role=admin&active=true" + ); + } + + // ── header style tests (from main) ─────────────────────────────────── + + #[test] + fn test_serialize_header_simple_primitive() { + let v = serialize_header_simple(&json!("hello"), None).unwrap(); + assert_eq!(v, "hello"); + } + + #[test] + fn test_serialize_header_simple_array_comma_joined() { + // simple/array: elements comma-joined, regardless of explode. + let value = json!(["a", "b"]); + let v = serialize_header_simple(&value, None).unwrap(); + assert_eq!(v, "a,b"); + } + + #[test] + fn test_serialize_header_simple_object_no_explode() { + // simple/object explode=false -> k,v,k2,v2 (keys sorted by Map order). + let value = json!({"k": "v", "k2": "v2"}); + let v = serialize_header_simple( + &value, + Some(&MethodParameter { + style: Some("simple".to_string()), + explode: Some(false), + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(v, "k,v,k2,v2"); + } + + #[test] + fn test_serialize_header_simple_object_explode() { + // simple/object explode=true -> k=v,k2=v2. + let value = json!({"k": "v", "k2": "v2"}); + let v = serialize_header_simple( + &value, + Some(&MethodParameter { + explode: Some(true), + ..Default::default() + }), + ) + .unwrap(); + assert_eq!(v, "k=v,k2=v2"); + } + + #[test] + fn test_serialize_header_simple_array_numbers() { + // non-string scalars render via value_to_query_string. + let value = json!([1, 2, 3]); + let v = serialize_header_simple(&value, None).unwrap(); + assert_eq!(v, "1,2,3"); + } + + #[test] + fn test_serialize_header_simple_rejects_control_chars() { + // CR/LF in a value would enable header injection — must be rejected. + let value = json!("a\r\nInjected: yes"); + assert!(serialize_header_simple(&value, None).is_err()); + } + + #[test] + fn test_serialize_header_simple_rejects_control_chars_in_array() { + let value = json!(["ok", "bad\nvalue"]); + assert!(serialize_header_simple(&value, None).is_err()); + } + + // ── path-param style tests ─────────────────────────────────────────── + + #[test] + fn test_serialize_path_param_default_simple_primitive() { + // No definition -> simple/primitive: just the encoded value. + assert_eq!(serialize_path_param("id", &json!("42"), None), "42"); + } + + #[test] + fn test_serialize_path_param_simple_array() { + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + "a,b" + ); + } + + #[test] + fn test_serialize_path_param_simple_object() { + // serde_json sorts object keys -> k1,v1,k2,v2 for this input. + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("filter", &json!({"k1": "v1", "k2": "v2"}), Some(&def)), + "k1,v1,k2,v2" + ); + } + + #[test] + fn test_serialize_path_param_simple_object_explode() { + let def = path_param("simple", Some(true)); + assert_eq!( + serialize_path_param("filter", &json!({"k1": "v1", "k2": "v2"}), Some(&def)), + "k1=v1,k2=v2" + ); + } + + #[test] + fn test_serialize_path_param_label_primitive() { + let def = path_param("label", None); + assert_eq!(serialize_path_param("id", &json!("42"), Some(&def)), ".42"); + } + + #[test] + fn test_serialize_path_param_label_array() { + // label/array/explode=false: members comma-joined after leading dot. + let def = path_param("label", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ".a,b" + ); + } + + #[test] + fn test_serialize_path_param_label_array_explode() { + // label/array/explode=true: members dot-joined after leading dot. + let def = path_param("label", Some(true)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ".a.b" + ); + } + + #[test] + fn test_serialize_path_param_label_object_no_explode() { + // label/object/explode=false: flat k,v,k,v comma-joined after leading dot. + let def = path_param("label", Some(false)); + assert_eq!( + serialize_path_param("color", &json!({"R": "100", "G": "200"}), Some(&def)), + ".G,200,R,100" + ); + } + + #[test] + fn test_serialize_path_param_label_object_explode() { + // label/object/explode=true: k=v pairs dot-joined after leading dot. + let def = path_param("label", Some(true)); + assert_eq!( + serialize_path_param("color", &json!({"R": "100", "G": "200"}), Some(&def)), + ".G=200.R=100" + ); + } + + #[test] + fn test_serialize_path_param_matrix_primitive() { + let def = path_param("matrix", None); + assert_eq!( + serialize_path_param("id", &json!("42"), Some(&def)), + ";id=42" + ); + } + + #[test] + fn test_serialize_path_param_matrix_array_no_explode() { + let def = path_param("matrix", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ";ids=a,b" + ); + } + + #[test] + fn test_serialize_path_param_matrix_array_explode() { + let def = path_param("matrix", Some(true)); + assert_eq!( + serialize_path_param("ids", &json!(["a", "b"]), Some(&def)), + ";ids=a;ids=b" + ); + } + + #[test] + fn test_serialize_path_param_matrix_object_explode() { + // matrix/object/explode=true: each k=v gets its own ;k=v prefix. + let def = path_param("matrix", Some(true)); + assert_eq!( + serialize_path_param("color", &json!({"R": "100", "G": "200"}), Some(&def)), + ";G=200;R=100" + ); + } + + #[test] + fn test_serialize_path_param_encodes_values_not_separators() { + // The structural commas stay literal; only the user values are + // percent-encoded (a space -> %20, a comma inside a value -> %2C). + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a b", "c,d"]), Some(&def)), + "a%20b,c%2Cd" + ); + } + + #[test] + fn test_serialize_path_param_matrix_encodes_value_not_prefix() { + let def = path_param("matrix", None); + assert_eq!( + serialize_path_param("id", &json!("a b"), Some(&def)), + ";id=a%20b" + ); + } + + #[test] + fn test_serialize_path_param_simple_array_with_null() { + // A null element serializes as an empty string. + let def = path_param("simple", Some(false)); + assert_eq!( + serialize_path_param("ids", &json!(["a", null, "b"]), Some(&def)), + "a,,b" + ); + } + + #[test] + fn test_render_path_template_label_style() { + let mut defs: HashMap = HashMap::new(); + defs.insert("id".to_string(), path_param("label", None)); + let mut params = Map::new(); + params.insert("id".to_string(), json!("42")); + let rendered = + render_path_template("/path/label/{id}", ¶ms, Some(&defs)).unwrap(); + assert_eq!(rendered, "/path/label/.42"); + } + + #[test] + fn test_render_path_template_no_defs_falls_back_to_simple() { + // Base-path placeholders pass `None` for defs -> plain encoded value. + let mut params = Map::new(); + params.insert("tenant".to_string(), json!("acme")); + let rendered = + render_path_template("/{tenant}/v1", ¶ms, None).unwrap(); + assert_eq!(rendered, "/acme/v1"); + } + + #[test] + fn test_render_path_template_rejects_dot_segment() { + let mut params = Map::new(); + params.insert("fileId".to_string(), json!("..")); + let result = render_path_template("/v1/files/{fileId}/content", ¶ms, None); + assert!(result.is_err(), "bare '..' must be rejected as a dot-segment"); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("dot-segment"), "error should mention dot-segment: {err_msg}"); + } + + #[test] + fn test_render_path_template_rejects_single_dot() { + let mut params = Map::new(); + params.insert("id".to_string(), json!(".")); + let result = render_path_template("/v1/users/{id}", ¶ms, None); + assert!(result.is_err(), "bare '.' must be rejected as a dot-segment"); + } + + #[test] + fn test_render_path_template_allows_dot_in_value() { + let mut params = Map::new(); + params.insert("id".to_string(), json!("user@gmail.com")); + let result = render_path_template("/v1/users/{id}", ¶ms, None); + assert!(result.is_ok(), "dots inside values must be allowed"); + assert_eq!(result.unwrap(), "/v1/users/user%40gmail.com"); + } + + #[test] + fn test_get_nested_str_simple() { + let val = json!({"nextPageToken": "tok123"}); + assert_eq!(get_nested_str(&val, "nextPageToken"), Some("tok123")); + } + + #[test] + fn test_get_nested_str_nested_path() { + let val = json!({"pagination": {"cursor": "abc"}}); + assert_eq!(get_nested_str(&val, "pagination.cursor"), Some("abc")); + } + + #[test] + fn test_get_nested_str_missing_returns_none() { + let val = json!({"other": "value"}); + assert_eq!(get_nested_str(&val, "nextPageToken"), None); + } + + #[test] + fn test_get_nested_str_non_string_returns_none() { + let val = json!({"count": 42}); + assert_eq!(get_nested_str(&val, "count"), None); + } + + // --------------------------------------------------------------- + // x-fern-sdk-return-value: dot-path resolution + // --------------------------------------------------------------- + + #[test] + fn test_get_nested_value_top_level_property() { + let val = json!({"data": [1, 2, 3], "meta": {}}); + assert_eq!(get_nested_value(&val, "data"), Some(&json!([1, 2, 3]))); + } + + #[test] + fn test_get_nested_value_nested_property() { + let val = json!({"result": {"items": ["x", "y"]}}); + assert_eq!( + get_nested_value(&val, "result.items"), + Some(&json!(["x", "y"])) + ); + } + + #[test] + fn test_get_nested_value_missing_top_returns_none() { + let val = json!({"other": 1}); + assert_eq!(get_nested_value(&val, "data"), None); + } + + #[test] + fn test_get_nested_value_missing_intermediate_returns_none() { + // First segment exists but the second doesn't — the executor + // must error rather than silently fall through. + let val = json!({"result": {"other": 1}}); + assert_eq!(get_nested_value(&val, "result.items"), None); + } + + #[test] + fn test_get_nested_value_returns_primitive_subvalue() { + // The extension is valid on a leaf primitive: e.g. an endpoint + // declaring `x-fern-sdk-return-value: id` on a wrapper response + // should surface the bare ID string. + let val = json!({"id": "abc-123", "name": "thing"}); + assert_eq!(get_nested_value(&val, "id"), Some(&json!("abc-123"))); + } + + #[test] + fn test_get_nested_value_empty_path_returns_none() { + let val = json!({"data": 1}); + assert_eq!(get_nested_value(&val, ""), None); + assert_eq!(get_nested_value(&val, " "), None); + } + + #[test] + fn test_get_nested_value_consecutive_dots_returns_none() { + // `a..b` would otherwise produce a segment lookup for the empty + // string, which always misses. Treat it explicitly as unresolved. + let val = json!({"a": {"b": 1}}); + assert_eq!(get_nested_value(&val, "a..b"), None); + } + + #[test] + fn test_get_nested_value_array_index() { + // Numeric segments index into arrays. `users.0.name` walks the + // first element of the `users` array and reads its `name`. + let val = json!({"users": [{"name": "alice"}, {"name": "bob"}]}); + assert_eq!( + get_nested_value(&val, "users.0.name"), + Some(&json!("alice")) + ); + assert_eq!( + get_nested_value(&val, "users.1.name"), + Some(&json!("bob")) + ); + } + + #[test] + fn test_get_nested_value_array_index_out_of_range_returns_none() { + let val = json!({"users": [{"name": "alice"}]}); + assert_eq!(get_nested_value(&val, "users.5.name"), None); + } + + #[test] + fn test_get_nested_value_array_index_on_object_returns_none() { + // `0` against a non-array, non-`"0"`-keyed object is a miss. + let val = json!({"users": {"alice": 1}}); + assert_eq!(get_nested_value(&val, "users.0"), None); + } + + #[test] + fn test_get_nested_value_object_key_named_zero_wins_over_array_index() { + // If an object happens to have a literal `"0"` key, prefer that + // over array indexing — the user's spec said "the property `0`", + // not "the zeroth element". We're not an array here anyway, but + // this also documents the precedence rule. + let val = json!({"0": "object-key-zero", "list": [10, 20, 30]}); + assert_eq!(get_nested_value(&val, "0"), Some(&json!("object-key-zero"))); + } + + #[test] + fn test_extract_return_value_top_level_resolves() { + let body = json!({"data": [1, 2], "meta": {"total": 2}}); + let out = extract_return_value(&body, Some("data"), false, "op").unwrap(); + assert_eq!(out, json!([1, 2])); + } + + #[test] + fn test_extract_return_value_nested_resolves() { + let body = json!({"result": {"items": [{"id": 1}]}}); + let out = extract_return_value(&body, Some("result.items"), false, "op").unwrap(); + assert_eq!(out, json!([{"id": 1}])); + } + + #[test] + fn test_extract_return_value_unresolved_path_errors() { + let body = json!({"foo": 1}); + let err = extract_return_value(&body, Some("data"), false, "things.list") + .expect_err("missing path must error"); + let msg = err.to_string(); + assert!( + msg.contains("'data'") && msg.contains("things.list"), + "error should name both path and operation id: {msg}", + ); + assert!( + msg.contains("--no-extract"), + "error should point users at the --no-extract escape hatch: {msg}", + ); + } + + #[test] + fn test_extract_return_value_no_path_returns_full_body() { + let body = json!({"data": [1], "meta": {}}); + let out = extract_return_value(&body, None, false, "op").unwrap(); + assert_eq!(out, body); + } + + #[test] + fn test_extract_return_value_no_extract_overrides_path() { + // The opt-out flag bypasses extraction entirely even when the + // spec declares a return path — used to debug responses that + // don't match the spec's promised shape. + let body = json!({"foo": 1}); + let out = extract_return_value(&body, Some("data"), true, "op") + .expect("no_extract=true must bypass extraction even if path would fail"); + assert_eq!( + out, body, + "no_extract returns the full body verbatim, including when the path would have errored", + ); + } + + #[test] + fn test_extract_return_value_resolved_null_is_preserved_not_errored() { + // `{"data": null}` + `return_value: "data"` is *not* an error — + // the spec promised a `data` field, the server delivered one, + // it just happens to be JSON null. Typed SDKs would surface + // this as a nullable response field; the CLI surfaces it as + // the literal `null`. + let body = json!({"data": null, "meta": {}}); + let out = extract_return_value(&body, Some("data"), false, "op") + .expect("resolved null is a valid extracted value"); + assert_eq!(out, json!(null)); + } + + #[test] + fn test_extract_return_value_descriptor_appears_verbatim_in_error() { + // When operationId is absent the caller passes a descriptor + // like "GET /reports". Make sure that descriptor survives the + // format string intact so users can locate the offending op. + let body = json!({"foo": 1}); + let err = extract_return_value(&body, Some("data"), false, "GET /reports") + .expect_err("missing path must error"); + let msg = err.to_string(); + assert!( + msg.contains("GET /reports"), + "descriptor must appear verbatim in error: {msg}", + ); + } + + #[test] + fn test_get_nested_value_path_through_array_with_index() { + // Composes `extract_return_value` with array indexing: paths + // like `data.0` extract the first element of an array. + let body = json!({"data": [{"id": "first"}, {"id": "second"}]}); + let out = extract_return_value(&body, Some("data.0"), false, "op").unwrap(); + assert_eq!(out, json!({"id": "first"})); + } + + #[tokio::test] + async fn test_handle_json_response_extracts_subvalue_capture() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"data":[{"id":1}],"meta":{"total":1}}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0], + json!([{"id": 1}]), + "captured value should be the extracted subvalue, not the full body", + ); + } + + #[tokio::test] + async fn test_handle_json_response_no_extract_keeps_full_body() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let body = r#"{"data":[{"id":1}],"meta":{"total":1}}"#; + let result = handle_json_response( + body, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + true, // no_extract + "things.list", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured[0], serde_json::from_str::(body).unwrap()); + } + + #[tokio::test] + async fn test_handle_json_response_extract_unresolved_errors() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let err = handle_json_response( + r#"{"foo":1}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + &mut pager_none, + ) + .await + .expect_err("unresolved extract path must surface as a validation error"); + assert!( + err.to_string().contains("'data'"), + "error message should name the missing path: {err}", + ); + assert_eq!( + pages_fetched, 0, + "errors must abort before the page counter advances", + ); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_with_extract_emits_subvalue_per_page() { + // Combined behavior check: per-op cursor pagination + extract. + // The full body is still used for pagination continuation (the + // cursor lives outside the extracted subvalue), but only the + // `data` subvalue is captured for the caller. + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "cursor".to_string(), + next_cursor: "next".to_string(), + results: "data".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"data":[{"id":1},{"id":2}],"next":"page-2"}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + Some("data"), + false, + "things.list", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(result, "cursor present → should continue pagination"); + assert_eq!(captured.len(), 1); + assert_eq!( + captured[0], + json!([{"id": 1}, {"id": 2}]), + "captured per-page value must be the extracted subvalue", + ); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "page-2"), + other => panic!("expected Cursor(Some(\"page-2\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_handle_json_response_capture_output() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"items":["a"]}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(captured.len(), 1); + assert_eq!(pages_fetched, 1); + } + + #[tokio::test] + async fn test_handle_json_response_non_json_body() { + let pagination = PaginationConfig::default(); + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + "not json at all", + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(pages_fetched, 0); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_continues() { + let pagination = PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let mut pager = None; + let result = handle_json_response( + r#"{"items":[],"nextPageToken":"next-tok"}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager, + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "next-tok"), + other => panic!("expected Cursor(Some(\"next-tok\")), got {other:?}"), + } + } + + /// Drive `handle_json_response` for a pagination variant and report whether + /// it chose to continue, plus the resulting page state. + async fn paginate_once( + endpoint_pag: &EndpointPagination, + body: &str, + request_url: &str, + ) -> (bool, PageState) { + let pagination = PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + cli_name: "pageguard".to_string(), + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::initial(Some(endpoint_pag)); + let mut captured = Vec::new(); + let mut pager = None; + let cont = handle_json_response( + body, + &pagination, + Some(endpoint_pag), + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + request_url, + &[], + None, + false, + "test-op", + &mut pager, + ) + .await + .unwrap(); + (cont, page_state) + } + + #[tokio::test] + #[serial_test::serial] + async fn test_pagination_uri_refuses_a_cross_host_next_url() { + // `next_uri` is taken from the response body, so without a guard the + // server picks the next request's host — and the credential goes with + // it. Pagination must halt instead of following. + // The guard must be active; `#[serial]` keeps this from racing other + // env-touching tests. + std::env::remove_var("PAGEGUARD_ALLOW_CROSS_HOST_PAGINATION"); + let pag = EndpointPagination::Uri { + next_uri: "next".into(), + results: "items".into(), + }; + let (cont, state) = paginate_once( + &pag, + r#"{"items":[1],"next":"https://evil.example.net/v1/things?cursor=2"}"#, + "https://api.example.com/v1/things", + ) + .await; + assert!(!cont, "pagination must not continue to another host"); + assert!( + !matches!(state, PageState::NextUrl(Some(_))), + "the off-host URL must not be stored as the next page, got {state:?}" + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_pagination_uri_follows_a_same_host_next_url() { + // The guard must be active; `#[serial]` keeps this from racing other + // env-touching tests. + std::env::remove_var("PAGEGUARD_ALLOW_CROSS_HOST_PAGINATION"); + let pag = EndpointPagination::Uri { + next_uri: "next".into(), + results: "items".into(), + }; + let (cont, state) = paginate_once( + &pag, + r#"{"items":[1],"next":"https://api.example.com/v1/things?cursor=2"}"#, + "https://api.example.com/v1/things", + ) + .await; + assert!(cont, "same-host pagination must still work"); + assert_eq!( + state.url_override(), + Some("https://api.example.com/v1/things?cursor=2") + ); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_pagination_path_refuses_an_absolute_cross_host_next_path() { + // `next_path` is usually relative, but an absolute URL replaces the + // base's origin — the same hole by a different route. + // The guard must be active; `#[serial]` keeps this from racing other + // env-touching tests. + std::env::remove_var("PAGEGUARD_ALLOW_CROSS_HOST_PAGINATION"); + let pag = EndpointPagination::Path { + next_path: "next".into(), + results: "items".into(), + }; + let (cont, state) = paginate_once( + &pag, + r#"{"items":[1],"next":"https://evil.example.net/v1/things?cursor=2"}"#, + "https://api.example.com/v1/things", + ) + .await; + assert!(!cont, "an absolute off-host next_path must not be followed"); + assert!(!matches!(state, PageState::NextUrl(Some(_))), "got {state:?}"); + } + + #[tokio::test] + #[serial_test::serial] + async fn test_pagination_path_still_resolves_a_relative_next_path() { + // The guard must be active; `#[serial]` keeps this from racing other + // env-touching tests. + std::env::remove_var("PAGEGUARD_ALLOW_CROSS_HOST_PAGINATION"); + let pag = EndpointPagination::Path { + next_path: "next".into(), + results: "items".into(), + }; + let (cont, state) = paginate_once( + &pag, + r#"{"items":[1],"next":"/v1/things?cursor=2"}"#, + "https://api.example.com/v1/things", + ) + .await; + assert!(cont, "relative pagination must be unaffected by the guard"); + assert_eq!( + state.url_override(), + Some("https://api.example.com/v1/things?cursor=2") + ); + } + + #[tokio::test] + async fn test_handle_json_response_pagination_at_limit() { + let pagination = PaginationConfig { + page_all: true, + page_limit: 5, + page_delay_ms: 0, + ..PaginationConfig::default() + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 4u32; // becomes 5 == page_limit, no continuation + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + + let mut pager = None; + let result = handle_json_response( + r#"{"items":[],"nextPageToken":"would-be-next"}"#, + &pagination, + None, + &pipeline, + &mut pages_fetched, + &mut page_state, + false, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager, + ) + .await + .unwrap(); + + assert!(!result); + assert_eq!(pages_fetched, 5); + } + + // --------------------------------------------------------------- + // Per-operation x-fern-pagination: cursor + offset coverage + // --------------------------------------------------------------- + + fn page_all_pagination() -> PaginationConfig { + PaginationConfig { + page_all: true, + page_limit: 10, + page_delay_ms: 0, + ..PaginationConfig::default() + } + } + + #[tokio::test] + async fn test_per_op_cursor_pagination_continues_with_response_path() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"entries":[{"id":"1"}],"next_marker":"abc"}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Cursor(Some(ref t)) => assert_eq!(t, "abc"), + other => panic!("expected Cursor(Some(\"abc\")), got {other:?}"), + } + } + + #[tokio::test] + async fn test_per_op_cursor_stops_on_empty_next_cursor() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Cursor(None); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"entries":[{"id":"2"}],"next_marker":""}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(!result); + } + + #[tokio::test] + async fn test_per_op_offset_pagination_advances_by_results_len() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: Some("meta.has_more".to_string()), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}],"meta":{"has_more":true}}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(result); + match page_state { + PageState::Offset(n) => assert_eq!(n, 3), + other => panic!("expected Offset(3), got {other:?}"), + } + } + + #[tokio::test] + async fn test_per_op_offset_stops_when_has_next_page_false() { + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: Some("meta.has_more".to_string()), + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + + let result = handle_json_response( + r#"{"users":[{"id":1}],"meta":{"has_more":false}}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &[], + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(!result); + } + + #[tokio::test] + async fn test_per_op_offset_step_stops_on_short_page() { + // `step: $request.limit` + caller's `--limit 50` → the executor + // gates the next page on `items.length >= 50`. The server returned + // only 3 rows (a short page), so pagination must stop even though + // `has_next_page` is unset. Matches upstream fern's hasNextPage + // check `items.length >= step`. + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "offset".to_string(), + results: "users".to_string(), + step: Some("limit".to_string()), + has_next_page: None, + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + let request_query_params = vec![("limit".to_string(), "50".to_string())]; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}]}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &request_query_params, + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!( + !result, + "short page (3 < 50) must end pagination per upstream `items.length >= step` gate" + ); + } + + #[tokio::test] + async fn test_per_op_offset_step_continues_on_full_page() { + // Full page: server returned `limit` items → continue and advance + // by `len(items)` (item-index semantics). + let pagination = page_all_pagination(); + let endpoint = EndpointPagination::Offset { + offset: "offset".to_string(), + results: "users".to_string(), + step: Some("limit".to_string()), + has_next_page: None, + }; + let pipeline = crate::formatter::OutputPipeline::default(); + let mut pages_fetched = 0u32; + let mut page_state = PageState::Offset(0); + let mut captured = Vec::new(); + let mut pager_none: Option = None; + let request_query_params = vec![("limit".to_string(), "3".to_string())]; + + let result = handle_json_response( + r#"{"users":[{"id":1},{"id":2},{"id":3}]}"#, + &pagination, + Some(&endpoint), + &pipeline, + &mut pages_fetched, + &mut page_state, + true, + &mut captured, + "http://example.com/test", + &request_query_params, + None, + false, + "test-op", + &mut pager_none, + ) + .await + .unwrap(); + + assert!(result, "full page (3 >= 3) must continue pagination"); + match page_state { + PageState::Offset(n) => assert_eq!( + n, 3, + "offset advances by len(items), not by the step value" + ), + other => panic!("expected Offset(3), got {other:?}"), + } + } + + #[test] + fn test_resolve_step_target_from_request_param() { + // step: "limit" + query params containing limit=50 → Some(50). + let params = vec![("limit".to_string(), "50".to_string())]; + assert_eq!(resolve_step_target(Some("limit"), ¶ms), Some(50)); + } + + #[test] + fn test_resolve_step_target_literal_integer() { + // step is itself an integer literal (e.g. `step: "50"`) → Some(50). + assert_eq!(resolve_step_target(Some("50"), &[]), Some(50)); + } + + #[test] + fn test_resolve_step_target_unresolvable_returns_none() { + // step references a param the caller didn't supply → None, so the + // executor falls back to the legacy `items.len() > 0` check. + assert_eq!(resolve_step_target(Some("limit"), &[]), None); + } + + #[test] + fn test_resolve_step_target_none_returns_none() { + assert_eq!(resolve_step_target(None, &[]), None); + } + + #[test] + fn test_page_state_injection_heuristic_first_page() { + let state = PageState::Cursor(None); + assert_eq!(state.injection(None, "pageToken"), None); + } + + #[test] + fn test_page_state_injection_heuristic_with_token() { + let state = PageState::Cursor(Some("tok".to_string())); + assert_eq!( + state.injection(None, "pageToken"), + Some(("pageToken".to_string(), "tok".to_string())), + ); + } + + #[test] + fn test_page_state_injection_endpoint_cursor_uses_op_param_name() { + let endpoint = EndpointPagination::Cursor { + cursor: "marker".to_string(), + next_cursor: "next_marker".to_string(), + results: "entries".to_string(), + }; + let state = PageState::Cursor(Some("tok".to_string())); + assert_eq!( + state.injection(Some(&endpoint), "pageToken"), + Some(("marker".to_string(), "tok".to_string())), + ); + } + + #[test] + fn test_page_state_injection_offset_zero_skipped_on_first_page() { + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: None, + }; + let state = PageState::Offset(0); + assert_eq!(state.injection(Some(&endpoint), "pageToken"), None); + } + + #[test] + fn test_page_state_injection_offset_nonzero_injects() { + let endpoint = EndpointPagination::Offset { + offset: "page_number".to_string(), + results: "users".to_string(), + step: None, + has_next_page: None, + }; + let state = PageState::Offset(42); + assert_eq!( + state.injection(Some(&endpoint), "pageToken"), + Some(("page_number".to_string(), "42".to_string())), + ); + } + + #[test] + fn test_mime_from_extension_various() { + assert_eq!(mime_from_extension("doc.txt"), Some("text/plain".to_string())); + assert_eq!(mime_from_extension("page.htm"), Some("text/html".to_string())); + assert_eq!(mime_from_extension("style.css"), Some("text/css".to_string())); + assert_eq!(mime_from_extension("data.xml"), Some("application/xml".to_string())); + assert_eq!(mime_from_extension("app.js"), Some("application/javascript".to_string())); + assert_eq!(mime_from_extension("doc.pdf"), Some("application/pdf".to_string())); + assert_eq!(mime_from_extension("arc.zip"), Some("application/zip".to_string())); + assert_eq!(mime_from_extension("file.gz"), Some("application/gzip".to_string())); + assert_eq!(mime_from_extension("file.gzip"), Some("application/gzip".to_string())); + assert_eq!(mime_from_extension("archive.tar"), Some("application/x-tar".to_string())); + assert_eq!(mime_from_extension("img.png"), Some("image/png".to_string())); + assert_eq!(mime_from_extension("photo.jpg"), Some("image/jpeg".to_string())); + assert_eq!(mime_from_extension("photo.jpeg"), Some("image/jpeg".to_string())); + assert_eq!(mime_from_extension("anim.gif"), Some("image/gif".to_string())); + assert_eq!(mime_from_extension("icon.svg"), Some("image/svg+xml".to_string())); + assert_eq!(mime_from_extension("img.webp"), Some("image/webp".to_string())); + assert_eq!(mime_from_extension("fav.ico"), Some("image/x-icon".to_string())); + assert_eq!(mime_from_extension("song.mp3"), Some("audio/mpeg".to_string())); + assert_eq!(mime_from_extension("sound.wav"), Some("audio/wav".to_string())); + assert_eq!(mime_from_extension("video.mp4"), Some("video/mp4".to_string())); + assert_eq!(mime_from_extension("clip.webm"), Some("video/webm".to_string())); + assert_eq!(mime_from_extension("config.yaml"), Some("application/yaml".to_string())); + assert_eq!(mime_from_extension("config.yml"), Some("application/yaml".to_string())); + assert_eq!(mime_from_extension("config.toml"), Some("application/toml".to_string())); + assert_eq!(mime_from_extension("word.doc"), Some("application/msword".to_string())); + assert_eq!(mime_from_extension("word.docx"), Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document".to_string())); + assert_eq!(mime_from_extension("sheet.xls"), Some("application/vnd.ms-excel".to_string())); + assert_eq!(mime_from_extension("sheet.xlsx"), Some("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".to_string())); + assert_eq!(mime_from_extension("slides.ppt"), Some("application/vnd.ms-powerpoint".to_string())); + assert_eq!(mime_from_extension("slides.pptx"), Some("application/vnd.openxmlformats-officedocument.presentationml.presentation".to_string())); + assert_eq!(mime_from_extension("module.wasm"), Some("application/wasm".to_string())); + assert_eq!(mime_from_extension("file.unknown"), None); + } + + #[test] + fn test_mime_to_extension_additional_branches() { + assert_eq!(mime_to_extension("image/gif"), "gif"); + // Use MIMEs that don't contain "xml" (which matches earlier in the chain) + assert_eq!(mime_to_extension("application/vnd.ms-excel.spreadsheet"), "xlsx"); + assert_eq!(mime_to_extension("application/vnd.ms-word.document.12"), "docx"); + assert_eq!(mime_to_extension("application/octet-stream"), "bin"); + assert_eq!(mime_to_extension("application/unknown-type"), "bin"); + } + + #[test] + fn test_extract_content_disposition_filename_quoted() { + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"voice.mp3\""), + Some("voice.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_unquoted() { + assert_eq!( + extract_content_disposition_filename("attachment; filename=voice.mp3"), + Some("voice.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_inline() { + // disposition type is irrelevant — `inline` should also yield the name. + assert_eq!( + extract_content_disposition_filename("inline; filename=\"page.pdf\""), + Some("page.pdf".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_missing() { + assert_eq!(extract_content_disposition_filename("attachment"), None); + assert_eq!(extract_content_disposition_filename(""), None); + } + + #[test] + fn test_extract_content_disposition_filename_rfc5987_unsupported_charset() { + // Charsets other than UTF-8 / ISO-8859-1 fall through and the caller + // ends up with the `download.` default. + assert_eq!( + extract_content_disposition_filename( + "attachment; filename*=Shift_JIS''%82%a0.mp3" + ), + None, + ); + } + + #[test] + fn test_extract_content_disposition_filename_strips_directory() { + // Server cannot escape its lane — directory components are discarded. + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"../etc/passwd\""), + Some("passwd".to_string()) + ); + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"/etc/passwd\""), + Some("passwd".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_rejects_dot_only() { + assert_eq!( + extract_content_disposition_filename("attachment; filename=\".\""), + None + ); + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"..\""), + None + ); + } + + #[test] + fn test_extract_content_disposition_filename_case_insensitive_param_name() { + // RFC 7231 §3.2.6: parameter names are case-insensitive. + assert_eq!( + extract_content_disposition_filename("attachment; Filename=\"voice.mp3\""), + Some("voice.mp3".to_string()) + ); + assert_eq!( + extract_content_disposition_filename("ATTACHMENT; FILENAME=voice.mp3"), + Some("voice.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_quoted_with_semicolon() { + // RFC 6266 / RFC 2616 quoted-string allows `;` inside DQUOTE. + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"hello;world.mp3\""), + Some("hello;world.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_ignores_form_data_disposition() { + // form-data is the multipart-upload variant; clients must not honor + // it as a download-name hint on a response (RFC 7578 §4.2). + assert_eq!( + extract_content_disposition_filename( + "form-data; name=\"file\"; filename=\"voice.mp3\"" + ), + None + ); + } + + #[test] + fn test_extract_content_disposition_filename_prefers_filename_star() { + // RFC 6266 §4.3: when both `filename` and `filename*` are present + // recipients MUST prefer `filename*` (the encoded i18n form). + assert_eq!( + extract_content_disposition_filename( + "attachment; filename=\"ascii.mp3\"; filename*=UTF-8''utf8-name.mp3" + ), + Some("utf8-name.mp3".to_string()) + ); + // Order in the header does not matter — `filename*` wins either way. + assert_eq!( + extract_content_disposition_filename( + "attachment; filename*=UTF-8''utf8-name.mp3; filename=\"ascii.mp3\"" + ), + Some("utf8-name.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_decodes_rfc5987_utf8() { + // RFC 5987: charset'lang'percent-encoded-bytes. We support UTF-8. + assert_eq!( + extract_content_disposition_filename( + "attachment; filename*=UTF-8''%E5%A3%B0.mp3" + ), + Some("声.mp3".to_string()) + ); + // ISO-8859-1 is also RFC-listed; supported as raw bytes (no decode). + assert_eq!( + extract_content_disposition_filename( + "attachment; filename*=ISO-8859-1''cafe.mp3" + ), + Some("cafe.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_falls_through_to_next_part() { + // If the first matching `filename=` sanitizes to None (empty, bidi + // override, dotfile, etc.) the parser must keep iterating and pick + // a valid later occurrence rather than shadowing it with None. + assert_eq!( + extract_content_disposition_filename( + "attachment; filename=\"\"; filename=\"real.mp3\"" + ), + Some("real.mp3".to_string()) + ); + } + + #[test] + fn test_extract_content_disposition_filename_rejects_empty_and_control() { + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"\""), + None + ); + assert_eq!( + extract_content_disposition_filename("attachment; filename=\"\r\n\""), + None + ); + } + + #[test] + fn test_sanitize_rejects_unicode_bidi_override() { + // U+202E (RIGHT-TO-LEFT OVERRIDE) is General_Category=Cf, not Cc — so + // char::is_control returns false. We must reject it explicitly to + // prevent server-controlled extension spoofing where the displayed + // name reads `invoice.jpg` but the saved file is actually `.exe`. + assert_eq!(sanitize_server_supplied_filename("invoice\u{202E}gpj.exe"), None); + // Other bidi/format chars in the same family. + assert_eq!(sanitize_server_supplied_filename("a\u{200E}b.mp3"), None); + assert_eq!(sanitize_server_supplied_filename("a\u{200F}b.mp3"), None); + assert_eq!(sanitize_server_supplied_filename("a\u{2066}b.mp3"), None); + assert_eq!(sanitize_server_supplied_filename("a\u{2069}b.mp3"), None); + } + + #[test] + fn test_sanitize_rejects_leading_dot_filenames() { + // A server choosing `.env`, `.bashrc`, etc. could silently overwrite + // a sensitive dotfile in the user's CWD. The default `download.` + // name is intentionally NOT a dotfile, so this rule only affects the + // Content-Disposition path. + assert_eq!(sanitize_server_supplied_filename(".env"), None); + assert_eq!(sanitize_server_supplied_filename(".bashrc"), None); + assert_eq!(sanitize_server_supplied_filename(".gitignore"), None); + // But a regular filename containing an internal dot is fine. + assert_eq!( + sanitize_server_supplied_filename("voice.mp3"), + Some("voice.mp3".to_string()) + ); + } + + #[test] + fn test_sanitize_rejects_embedded_backslashes() { + // Path::file_name treats backslash as a regular char on Unix, so a + // Windows-style traversal slips through Path::file_name unchanged. + // Reject explicitly to keep the cross-platform contract straight. + assert_eq!( + sanitize_server_supplied_filename("..\\..\\.ssh\\authorized_keys"), + None + ); + assert_eq!(sanitize_server_supplied_filename("a\\b.mp3"), None); + } + + #[test] + fn test_mime_to_extension_audio_subtypes_disambiguated() { + // `audio/mpegurl` (M3U/M3U8 playlists, IANA-registered) must not be + // absorbed into the `audio/mpeg` → mp3 branch via prefix matching. + assert_eq!(mime_to_extension("audio/mpegurl"), "m3u"); + assert_eq!(mime_to_extension("audio/x-mpegurl"), "m3u"); + // `audio/wavpack` (IANA-registered WavPack lossless codec) must not + // collapse into `audio/wav`. + assert_eq!(mime_to_extension("audio/wavpack"), "wv"); + // The original `audio/mpeg` and `audio/wav` branches still resolve + // correctly, with or without trailing parameters. + assert_eq!(mime_to_extension("audio/mpeg"), "mp3"); + assert_eq!(mime_to_extension("audio/mpeg; codecs=mp3"), "mp3"); + assert_eq!(mime_to_extension("audio/wav"), "wav"); + assert_eq!(mime_to_extension("audio/wav; rate=44100"), "wav"); + } + + #[test] + fn test_mime_to_extension_audio_and_video() { + // Audio/MPEG was the original FER-10871 regression — TTS-style + // audio responses silently dropped through to `.bin`. + assert_eq!(mime_to_extension("audio/mpeg"), "mp3"); + assert_eq!(mime_to_extension("audio/mp3"), "mp3"); + assert_eq!(mime_to_extension("audio/wav"), "wav"); + assert_eq!(mime_to_extension("audio/x-wav"), "wav"); + assert_eq!(mime_to_extension("audio/wave"), "wav"); + assert_eq!(mime_to_extension("audio/ogg"), "ogg"); + assert_eq!(mime_to_extension("audio/opus"), "opus"); + assert_eq!(mime_to_extension("audio/flac"), "flac"); + assert_eq!(mime_to_extension("audio/aac"), "aac"); + assert_eq!(mime_to_extension("audio/mp4"), "m4a"); + assert_eq!(mime_to_extension("audio/webm"), "weba"); + // video — `video/mpeg` must NOT cross-collide with `audio/mpeg`. + assert_eq!(mime_to_extension("video/mp4"), "mp4"); + assert_eq!(mime_to_extension("video/webm"), "webm"); + assert_eq!(mime_to_extension("video/quicktime"), "mov"); + assert_eq!(mime_to_extension("video/mpeg"), "mpeg"); + // images + assert_eq!(mime_to_extension("image/svg+xml"), "svg"); + assert_eq!(mime_to_extension("image/webp"), "webp"); + // case-insensitivity per RFC 6838 + assert_eq!(mime_to_extension("Audio/MPEG"), "mp3"); + // charset / parameter suffix (e.g. `audio/mpeg; codecs=...`) is harmless + assert_eq!(mime_to_extension("audio/mpeg; codecs=mp3"), "mp3"); + } + + #[test] + fn test_resolve_upload_mime_strips_control_chars() { + let mime = resolve_upload_mime(Some("text/plain\rinjected"), None, &None); + assert_eq!(mime, "text/plaininjected"); + } + + #[test] + fn test_resolve_upload_mime_all_control_chars_falls_back() { + let mime = resolve_upload_mime(Some("\r\n\t"), None, &None); + assert_eq!(mime, "application/octet-stream"); + } + + #[test] + fn test_value_to_query_string_null() { + assert_eq!(value_to_query_string(&Value::Null), ""); + } + + #[test] + fn test_value_to_query_string_object_serializes() { + let val = json!({"key": "val"}); + let result = value_to_query_string(&val); + assert!(!result.is_empty()); + } + + #[test] + fn test_serialize_deep_object_non_object_value() { + let result = serialize_deep_object("filter", &json!("simple")); + assert_eq!(result, vec![("filter".to_string(), "simple".to_string())]); + } + + #[test] + fn test_serialize_deep_object_nested() { + // Multi-level nesting: {"meta":{"created_at":"today"}} with key "filter" + // should produce [("filter[meta][created_at]", "today")] + let value = json!({"meta": {"created_at": "today"}}); + let result = serialize_deep_object("filter", &value); + assert_eq!( + result, + vec![("filter[meta][created_at]".to_string(), "today".to_string())] + ); + } + + #[test] + fn test_serialize_deep_object_array_uses_repeated_keys() { + // Arrays must use repeated keys (filter[tags]=a&filter[tags]=b), + // consistent with the Fern Python and C# SDKs. Not indexed brackets. + let value = json!({"tags": ["a", "b"]}); + let mut result = serialize_deep_object("filter", &value); + result.sort(); // order not guaranteed + assert_eq!( + result, + vec![ + ("filter[tags]".to_string(), "a".to_string()), + ("filter[tags]".to_string(), "b".to_string()), + ] + ); + } + + #[test] + fn test_build_url_uses_root_url_and_service_path_when_no_base_url() { + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/files"); + } + + #[test] + fn test_build_url_method_root_url_overrides_doc_root_url() { + // Per-operation server override: method.root_url must win over doc.root_url. + // If this is broken, requests route to the wrong host (e.g. Box uploads + // go to api.box.com instead of upload.box.com). + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "uploads".to_string(), + root_url: "https://upload.example.com/".to_string(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://upload.example.com/v1/uploads"); + } + + #[test] + fn test_build_url_empty_method_root_url_falls_back_to_doc() { + // When method.root_url is empty (unset), doc.root_url must be used. + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + base_url: None, + ..Default::default() + }; + let method = RestMethod { + path: "files".to_string(), + root_url: String::new(), + ..Default::default() + }; + let (url, _) = build_url(&doc, &method, &Map::new(), false, None).unwrap(); + assert_eq!(url, "https://api.example.com/v1/files"); + } + + #[test] + fn test_parse_and_validate_inputs_invalid_params_json() { + let doc = RestDescription::default(); + let method = RestMethod::default(); + let err = + parse_and_validate_inputs(&doc, &method, Some("{not json}"), None, false, None, &[], &[]).unwrap_err(); + assert!(err.to_string().contains("Invalid --params JSON")); + } + + #[test] + fn test_parse_and_validate_inputs_invalid_body_json() { + let doc = RestDescription::default(); + let method = RestMethod::default(); + let err = + parse_and_validate_inputs(&doc, &method, None, Some("{not json}"), false, None, &[], &[]).unwrap_err(); + assert!(err.to_string().contains("Invalid --json body")); + } + + #[test] + fn test_parse_and_validate_inputs_required_query_param_missing() { + let mut parameters = HashMap::new(); + parameters.insert( + "api_key".to_string(), + MethodParameter { + location: Some("query".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription::default(); + let method = RestMethod { + parameters, + ..Default::default() + }; + let err = parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &[]).unwrap_err(); + assert!(err.to_string().contains("Required parameter 'api_key'")); + } + + #[tokio::test] + async fn test_build_http_request_unsupported_method() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "TRACE".to_string(), + path: "test".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let err = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("Unsupported HTTP method")); + } + + #[tokio::test] + async fn test_build_http_request_put_patch_delete() { + let client = reqwest::Client::new(); + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + for http_method in &["PUT", "PATCH", "DELETE"] { + let method = RestMethod { + http_method: http_method.to_string(), + path: "test".to_string(), + ..Default::default() + }; + let result = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await; + assert!(result.is_ok(), "Failed for method {http_method}"); + } + } + + #[test] + fn test_validate_value_schema_not_found() { + let doc = RestDescription::default(); + let mut errors = Vec::new(); + validate_value(&json!({}), "NonExistentSchema", &doc, "$", &mut errors); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains("Schema 'NonExistentSchema' not found")); + } + + #[test] + fn test_validate_value_nullable_schema_accepts_null() { + // A `$ref`-resolved schema with `nullable: true` must accept + // JSON null. Without the null short-circuit in `validate_value`, + // the object branch fires and emits "Expected object". + let schemas = HashMap::from([( + "NullableObj".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + nullable: true, + properties: HashMap::from([( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let mut errors = Vec::new(); + validate_value(&Value::Null, "NullableObj", &doc, "body", &mut errors); + assert!( + errors.is_empty(), + "null on a nullable schema must be accepted, got: {errors:?}", + ); + } + + #[test] + fn test_validate_value_nullable_union_composition_accepts_null() { + // A component schema declared as an object with a nullable-union + // composition (`anyOf: [string, null]` on the schema root) must + // accept null when accessed via `$ref`. + let schemas = HashMap::from([( + "NullableUnionObj".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::from([( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + any_of: vec![ + JsonSchemaProperty { + prop_type: Some("object".to_string()), + ..Default::default() + }, + JsonSchemaProperty { + prop_type: Some("null".to_string()), + ..Default::default() + }, + ], + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let mut errors = Vec::new(); + validate_value(&Value::Null, "NullableUnionObj", &doc, "body", &mut errors); + assert!( + errors.is_empty(), + "null on a schema with an anyOf null branch must be accepted, got: {errors:?}", + ); + } + + #[test] + fn test_validate_value_non_nullable_schema_rejects_null() { + // Guard: a non-nullable object schema must still reject null. + let schemas = HashMap::from([( + "StrictObj".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: HashMap::from([( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + )]), + ..Default::default() + }, + )]); + let doc = RestDescription { schemas, ..Default::default() }; + let mut errors = Vec::new(); + validate_value(&Value::Null, "StrictObj", &doc, "body", &mut errors); + assert!( + !errors.is_empty(), + "null on a non-nullable schema must be rejected", + ); + } + + #[test] + fn test_resolve_next_path_absolute_url_overrides_base() { + // Server returned a fully-formed URL → use it verbatim. + let url = resolve_next_path( + "https://api.example.com/v1/things?cursor=a", + "https://other.example.com/v2/items?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://other.example.com/v2/items?cursor=b"); + } + + #[test] + fn test_resolve_next_path_absolute_path_keeps_scheme_and_host() { + // A `/`-prefixed path keeps the previous host but replaces the path. + let url = resolve_next_path( + "https://api.example.com/v1/things?cursor=a", + "/v1/things?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://api.example.com/v1/things?cursor=b"); + } + + #[test] + fn test_resolve_next_path_relative_path_inherits_directory() { + // No leading slash → resolved relative to the previous request's + // directory (browser-style URL resolution). + let url = resolve_next_path( + "https://api.example.com/v1/things", + "things?cursor=b", + ) + .unwrap(); + assert_eq!(url, "https://api.example.com/v1/things?cursor=b"); + } + + #[test] + fn test_resolve_next_path_rejects_invalid_base_url() { + let err = resolve_next_path("not a url", "/foo").unwrap_err(); + assert!(err.contains("not a valid URL"), "got: {err}"); + } + + #[test] + fn test_page_state_initial_for_each_form() { + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Cursor { + cursor: "c".into(), + next_cursor: "n".into(), + results: "r".into(), + })), + PageState::Cursor(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Offset { + offset: "o".into(), + results: "r".into(), + step: None, + has_next_page: None, + })), + PageState::Offset(0) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Uri { + next_uri: "n".into(), + results: "r".into(), + })), + PageState::NextUrl(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Path { + next_path: "n".into(), + results: "r".into(), + })), + PageState::NextUrl(None) + )); + assert!(matches!( + PageState::initial(Some(&EndpointPagination::Custom { + results: "r".into(), + })), + PageState::Custom + )); + assert!(matches!(PageState::initial(None), PageState::Cursor(None))); + } + + #[test] + fn test_page_state_url_override_only_for_next_url() { + assert!(PageState::Cursor(None).url_override().is_none()); + assert!(PageState::Cursor(Some("tok".into())).url_override().is_none()); + assert!(PageState::Offset(5).url_override().is_none()); + assert!(PageState::NextUrl(None).url_override().is_none()); + assert!(PageState::Custom.url_override().is_none()); + let url = "https://api.example.com/v1/things?cursor=abc"; + assert_eq!( + PageState::NextUrl(Some(url.to_string())).url_override(), + Some(url) + ); + } + + #[test] + fn test_page_state_injection_uri_path_custom_no_query_param() { + // Uri/Path/Custom embed everything in the URL (or stop entirely); + // they must never push a cursor/offset query param. + let pagination = EndpointPagination::Uri { + next_uri: "next".into(), + results: "items".into(), + }; + assert!(PageState::NextUrl(Some("https://x".into())) + .injection(Some(&pagination), "page_token") + .is_none()); + let custom = EndpointPagination::Custom { + results: "items".into(), + }; + assert!(PageState::Custom.injection(Some(&custom), "page_token").is_none()); + } + + // ----------------------------------------------------------------- + // x-fern-streaming response decoding (`decode_stream_event`) + // + // The pure line decoder is the surface most likely to drift across + // server quirks (extra whitespace, comment lines, terminator + // variants), so we cover the full matrix here without touching the + // network. Wire-level integration is exercised in the tier-2 tests + // under tests/openapi_fixture_wire.rs. + // ----------------------------------------------------------------- + + // ----------------------------------------------------------------- + // SSE line decoding (`SseLineDecoder`) + // + // SSE is stateful — `data:` payloads are buffered across multiple + // lines and dispatched on a blank-line separator per the WHATWG + // spec. Tests below isolate the decoder so a regression in framing + // or multi-line concat points at the exact branch. + // ----------------------------------------------------------------- + + fn drive(lines: &[&str]) -> Vec { + let mut decoder = SseLineDecoder::default(); + let mut out = Vec::new(); + for line in lines { + if let Some(payload) = decoder.push_line(line) { + out.push(payload); + } + } + if let Some(payload) = decoder.flush() { + out.push(payload); + } + out + } + + #[test] + fn test_sse_decoder_strips_data_prefix_and_one_space() { + // `data: {"x":1}` decodes to `{"x":1}` — the single leading + // space after `data:` is consumed (matches the SSE spec). + let payloads = drive(&["data: {\"x\":1}", ""]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_no_space_after_data() { + // The space after `data:` is optional; the payload is + // preserved identically in both shapes. + let payloads = drive(&["data:{\"x\":1}", ""]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_skips_comments_and_unknown_fields() { + // Comments (`:`), `event:`, `id:`, `retry:`, and unknown + // fields are framing-only and must not pollute the dispatched + // payload. Only the `data:` line contributes to the event. + let payloads = drive(&[ + ": keepalive", + "event: message", + "id: 42", + "retry: 5000", + "data: {\"x\":1}", + "", + ]); + assert_eq!(payloads, vec!["{\"x\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_dispatches_on_blank_line_with_multiline_concat() { + // Three `data:` lines spanning a single pretty-printed JSON + // object — the WHATWG spec says they join with `\n` and + // dispatch as one event on the blank-line separator. The TS + // runtime's `iterSseEvents` loop does exactly this. + let payloads = drive(&[ + "data: {", + "data: \"foo\": 1", + "data: }", + "", + ]); + assert_eq!(payloads, vec!["{\n \"foo\": 1\n}".to_string()]); + } + + #[test] + fn test_sse_decoder_dispatches_two_events_separated_by_blank() { + let payloads = drive(&[ + "data: {\"step\":1}", + "", + "data: {\"step\":2}", + "", + ]); + assert_eq!( + payloads, + vec!["{\"step\":1}".to_string(), "{\"step\":2}".to_string(),] + ); + } + + #[test] + fn test_sse_decoder_flushes_final_event_without_blank_line() { + // EOF flush: when the server closes the connection without + // sending the trailing blank line, the buffered event must + // still be dispatched. Mirrors the TS post-loop + // `if (dataValue != null)` block. + let payloads = drive(&["data: {\"step\":1}"]); + assert_eq!(payloads, vec!["{\"step\":1}".to_string()]); + } + + #[test] + fn test_sse_decoder_blank_line_without_buffered_data_dispatches_nothing() { + // Resetting on blank without a buffered `data:` must not + // dispatch — an `event:` line followed by a blank line is + // discarded entirely. + let payloads = drive(&["event: ping", ""]); + assert!(payloads.is_empty(), "got unexpected events: {payloads:?}"); + } + + #[test] + fn test_decode_ndjson_emits_whole_line() { + let cfg = StreamingConfig::Json { terminator: None }; + assert_eq!( + decode_stream_event(&cfg, "{\"x\":1}"), + StreamEvent::Event("{\"x\":1}".to_string()) + ); + } + + #[test] + fn test_decode_ndjson_skips_blank_lines() { + // Some servers emit blank keepalive lines between records. + let cfg = StreamingConfig::Json { terminator: None }; + assert_eq!(decode_stream_event(&cfg, ""), StreamEvent::Skip); + } + + #[test] + fn test_decode_ndjson_terminator_only_when_configured() { + // Without a configured terminator, a literal `[DONE]` payload + // is just another event — NDJSON has no implicit sentinel. + let no_term = StreamingConfig::Json { terminator: None }; + assert_eq!( + decode_stream_event(&no_term, "[DONE]"), + StreamEvent::Event("[DONE]".to_string()) + ); + let with_term = StreamingConfig::Json { + terminator: Some("__END__".to_string()), + }; + assert_eq!( + decode_stream_event(&with_term, "__END__"), + StreamEvent::Terminate + ); + } + + #[test] + fn test_decode_text_emits_each_line_verbatim() { + // Plain-text format: no JSON parse, no SSE prefix strip, no + // terminator. Each non-empty line flows through as a string. + let cfg = StreamingConfig::Text; + assert_eq!( + decode_stream_event(&cfg, "hello world"), + StreamEvent::Event("hello world".to_string()) + ); + assert_eq!( + decode_stream_event(&cfg, "data: not stripped"), + StreamEvent::Event("data: not stripped".to_string()) + ); + assert_eq!( + decode_stream_event(&cfg, "{\"not\":\"parsed\"}"), + StreamEvent::Event("{\"not\":\"parsed\"}".to_string()) + ); + } + + #[test] + fn test_decode_text_skips_blank_lines() { + // Mirrors the C# generator's + // `if(!string.IsNullOrEmpty(line)) yield return line` guard. + let cfg = StreamingConfig::Text; + assert_eq!(decode_stream_event(&cfg, ""), StreamEvent::Skip); + } + + #[test] + fn test_project_text_event_bypasses_return_value_projection() { + // Text streams emit a raw line; `x-fern-sdk-return-value` + // and `--no-extract` are both no-ops because there's no + // JSON object to project against. + let cfg = StreamingConfig::Text; + let value = project_stream_event( + &cfg, + "raw line", + Some("$response.does.not.exist"), + false, + "test op", + ) + .expect("text projection must succeed"); + assert_eq!(value, Value::String("raw line".to_string())); + } + + // --------------------------------------------------------------- + // SSE content-type auto-detection helpers + // --------------------------------------------------------------- + + /// Verifies the auto-detection predicate: a response whose + /// Content-Type is `text/event-stream` AND whose method has no + /// `x-fern-streaming` config should be routed to the SSE branch. + #[test] + fn test_sse_autodetect_triggers_on_event_stream_content_type() { + let content_type = "text/event-stream"; + let method = RestMethod::default(); + assert!( + content_type.contains("text/event-stream") && method.streaming.is_none(), + "auto-detect must trigger when content_type is text/event-stream and streaming is None" + ); + } + + #[test] + fn test_sse_autodetect_triggers_with_charset_suffix() { + // Servers may send `text/event-stream; charset=utf-8` + let content_type = "text/event-stream; charset=utf-8"; + let method = RestMethod::default(); + assert!( + content_type.contains("text/event-stream") && method.streaming.is_none(), + "auto-detect must trigger even with charset parameter" + ); + } + + #[test] + fn test_sse_autodetect_skipped_when_streaming_configured() { + let content_type = "text/event-stream"; + let method = RestMethod { + streaming: Some(StreamingConfig::Sse { terminator: None }), + ..Default::default() + }; + assert!( + !(content_type.contains("text/event-stream") && method.streaming.is_none()), + "auto-detect must NOT trigger when x-fern-streaming is already set" + ); + } + + #[test] + fn test_sse_autodetect_skipped_for_json_content_type() { + let content_type = "application/json"; + let method = RestMethod::default(); + assert!( + !(content_type.contains("text/event-stream") && method.streaming.is_none()), + "auto-detect must NOT trigger for application/json" + ); + } + + #[test] + fn test_sse_autodetect_synthesized_config_is_sse_no_terminator() { + // The auto-detected config must be SSE with no terminator, + // matching what `StreamingConfig::Sse { terminator: None }` + // produces. + let config = StreamingConfig::Sse { terminator: None }; + match &config { + StreamingConfig::Sse { terminator } => { + assert!(terminator.is_none(), "auto-detected SSE must have no terminator"); + } + _ => panic!("expected Sse variant"), + } + } +} + +#[tokio::test] +async fn test_execute_method_dry_run() { + let mut schemas = HashMap::new(); + let mut properties = HashMap::new(); + properties.insert( + "name".to_string(), + crate::openapi::discovery::JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + schemas.insert( + "File".to_string(), + crate::openapi::discovery::JsonSchema { + schema_type: Some("object".to_string()), + properties, + ..Default::default() + }, + ); + + let doc = RestDescription { + root_url: "https://example.googleapis.com/".to_string(), + service_path: "v1/".to_string(), + schemas, + ..Default::default() + }; + + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + + let method = RestMethod { + http_method: "POST".to_string(), + id: Some("example.files.create".to_string()), + path: "files/{fileId}".to_string(), + parameter_order: vec!["fileId".to_string()], + parameters, + request: Some(crate::openapi::discovery::SchemaRef { + schema_ref: Some("File".to_string()), + parameter_name: None, + }), + ..Default::default() + }; + + let params_json = r#"{"fileId": "123"}"#; + let body_json = r#"{"name": "test.txt"}"#; + + let pagination = PaginationConfig::default(); + + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let result = execute_method( + &doc, + &method, + Some(params_json), + Some(body_json), + &crate::auth::no_auth_provider(), + None, + None, + None, + None, // multipart_parts + true, // dry_run + &pagination, + &crate::formatter::OutputPipeline::default(), + false, + None, + &http_config, + false, // no_extract + false, // no_retry + false, // no_stream + false, // debug + &[], + &[], + ) + .await; + + assert!(result.is_ok()); +} + +#[tokio::test] +async fn test_dry_run_redacts_credential_headers() { + // `--dry-run` output is routinely pasted into bug reports, so it must not + // print the credential. The value reaches `header_params` whenever the spec + // models it as a header parameter — an `apiKey`-in-header scheme, which is + // the shape the spec below declares. + let mut security_schemes = HashMap::new(); + security_schemes.insert( + "ApiKeyAuth".to_string(), + crate::openapi::discovery::SecurityScheme::ApiKeyHeader { + name: "xi-api-key".to_string(), + }, + ); + let doc = RestDescription { + root_url: "https://api.example.com/".to_string(), + service_path: "v1/".to_string(), + security_schemes, + ..Default::default() + }; + + let mut parameters = HashMap::new(); + for name in ["xi-api-key", "Authorization", "X-Request-Id"] { + parameters.insert( + name.to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("header".to_string()), + ..Default::default() + }, + ); + } + let method = RestMethod { + http_method: "GET".to_string(), + id: Some("things.list".to_string()), + path: "things".to_string(), + parameters, + ..Default::default() + }; + + let params_json = r#"{"xi-api-key":"sk-secret-value","Authorization":"Bearer tok-secret","X-Request-Id":"req-42"}"#; + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let out = execute_method( + &doc, + &method, + Some(params_json), + None, + &crate::auth::no_auth_provider(), + None, + None, + None, + None, + true, // dry_run + &PaginationConfig::default(), + &crate::formatter::OutputPipeline::default(), + true, // capture_output — returns the dry-run JSON instead of printing + None, + &http_config, + false, + false, + false, + false, // debug off: redaction must not depend on --debug + &[], + &[], + ) + .await + .expect("dry run should succeed") + .expect("capture_output should return the dry-run info"); + + let rendered = serde_json::to_string(&out).unwrap(); + assert!( + !rendered.contains("sk-secret-value"), + "the spec-declared api key must be redacted, got: {rendered}" + ); + assert!( + !rendered.contains("tok-secret"), + "a well-known credential header must be redacted, got: {rendered}" + ); + assert!( + rendered.contains("[REDACTED]"), + "redaction should be visible in the output, got: {rendered}" + ); + // Non-credential headers stay legible — redaction must not blind the flag. + assert!( + rendered.contains("req-42"), + "non-sensitive headers should still be shown, got: {rendered}" + ); +} + +#[tokio::test] +async fn test_execute_method_missing_path_param() { + // Same setup but missing required fileId in params + let mut parameters = HashMap::new(); + parameters.insert( + "fileId".to_string(), + crate::openapi::discovery::MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription::default(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "files/{fileId}".to_string(), + parameter_order: vec!["fileId".to_string()], + parameters, + ..Default::default() + }; + + let http_config = crate::http::HttpConfig::new("test").unwrap(); + let result = execute_method( + &doc, + &method, + None, // No params provided + None, + &crate::auth::no_auth_provider(), + None, + None, + None, + None, // multipart_parts + true, + &PaginationConfig::default(), + &crate::formatter::OutputPipeline::default(), + false, + None, + &http_config, + false, // no_extract + false, // no_retry + false, // no_stream + false, // debug + &[], + &[], + ) + .await; + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Required path parameter")); +} + +#[test] +fn test_get_value_type_helper() { + assert_eq!(get_value_type(&json!(null)), "null"); + assert_eq!(get_value_type(&json!(true)), "boolean"); + assert_eq!(get_value_type(&json!(42)), "integer"); + assert_eq!(get_value_type(&json!(3.5)), "number (float)"); + assert_eq!(get_value_type(&json!("string")), "string"); + assert_eq!(get_value_type(&json!([1, 2])), "array"); + assert_eq!(get_value_type(&json!({"a": 1})), "object"); +} + +#[tokio::test] +async fn test_post_without_body_sets_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "messages/trash".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/messages/trash".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert_eq!( + built + .headers() + .get("Content-Length") + .map(|v| v.to_str().unwrap()), + Some("0"), + "POST with no body must include Content-Length: 0" + ); +} + +#[tokio::test] +async fn test_post_with_body_does_not_add_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "POST".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: Some(json!({"name": "test"})), + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + // When body is present, Content-Length should NOT be "0" + let cl = built + .headers() + .get("Content-Length") + .map(|v| v.to_str().unwrap().to_string()); + assert!(cl.is_none() || cl.as_deref() != Some("0")); +} + +#[tokio::test] +async fn test_get_does_not_set_content_length_zero() { + let client = reqwest::Client::new(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "files".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/files".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let request = build_http_request( + &client, + &method, + &input, + &crate::auth::no_auth_provider(), + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + assert!( + built.headers().get("Content-Length").is_none(), + "GET with no body should not have Content-Length header" + ); +} + +// --------------------------------------------------------------------------- +// BearerHeader auth method +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_bearer_header_sends_bearer_prefix() { + use crate::openapi::discovery::RestMethod; + + let client = crate::http::HttpConfig::new("test").unwrap().build_client().unwrap(); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/test".to_string(), + ..Default::default() + }; + let input = ExecutionInput { + full_url: "https://example.com/test".to_string(), + body: None, + query_params: Vec::new(), + header_params: Vec::new(), + is_upload: false, + }; + + let provider: DynAuthProvider = std::sync::Arc::new(crate::auth::HeaderAuthProvider::new( + "scheme", + "X-Auth", + crate::auth::AuthCredentialSource::literal("mytoken"), + true, + )); + let request = build_http_request( + &client, + &method, + &input, + &provider, + &EndpointAuthMetadata::unspecified(), + &PageState::Cursor(None), + 0, + &None, + None, + &None, + &PaginationConfig::default(), + ) + .await + .unwrap(); + + let built = request.build().unwrap(); + let header_val = built.headers().get("x-auth").and_then(|v| v.to_str().ok()); + assert_eq!(header_val, Some("Bearer mytoken")); +} + +// --------------------------------------------------------------- +// HTTP format helpers +// --------------------------------------------------------------- + +#[test] +fn format_http_version_known_versions() { + assert_eq!(format_http_version(reqwest::Version::HTTP_09), "HTTP/0.9"); + assert_eq!(format_http_version(reqwest::Version::HTTP_10), "HTTP/1.0"); + assert_eq!(format_http_version(reqwest::Version::HTTP_11), "HTTP/1.1"); + assert_eq!(format_http_version(reqwest::Version::HTTP_2), "HTTP/2"); + assert_eq!(format_http_version(reqwest::Version::HTTP_3), "HTTP/3"); +} + +#[test] +fn write_http_preamble_basic() { + let mut buf = Vec::new(); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers.insert("x-request-id", "abc123".parse().unwrap()); + + write_http_preamble( + &mut buf, + reqwest::Version::HTTP_11, + reqwest::StatusCode::OK, + &headers, + ) + .unwrap(); + + let output = String::from_utf8(buf).unwrap(); + assert!( + output.starts_with("HTTP/1.1 200 OK\r\n"), + "should start with status line, got: {output}" + ); + assert!( + output.contains("content-type: application/json\r\n"), + "should include content-type header, got: {output}" + ); + assert!( + output.contains("x-request-id: abc123\r\n"), + "should include custom header, got: {output}" + ); + assert!( + output.ends_with("\r\n\r\n"), + "should end with blank line separator, got: {output}" + ); +} + +#[test] +fn write_http_preamble_error_status() { + let mut buf = Vec::new(); + let headers = reqwest::header::HeaderMap::new(); + + write_http_preamble( + &mut buf, + reqwest::Version::HTTP_11, + reqwest::StatusCode::NOT_FOUND, + &headers, + ) + .unwrap(); + + let output = String::from_utf8(buf).unwrap(); + assert!( + output.starts_with("HTTP/1.1 404 Not Found\r\n"), + "should show 404 status line, got: {output}" + ); +} + +#[test] +fn write_http_preamble_no_headers() { + let mut buf = Vec::new(); + let headers = reqwest::header::HeaderMap::new(); + + write_http_preamble( + &mut buf, + reqwest::Version::HTTP_2, + reqwest::StatusCode::NO_CONTENT, + &headers, + ) + .unwrap(); + + let output = String::from_utf8(buf).unwrap(); + assert_eq!( + output, "HTTP/2 204 No Content\r\n\r\n", + "empty headers should produce status line + blank line" + ); +} + +#[test] +fn write_http_preamble_binary_header_value() { + let mut buf = Vec::new(); + let mut headers = reqwest::header::HeaderMap::new(); + // Insert a header value containing non-visible ASCII (bytes that + // fail `HeaderValue::to_str`). `HeaderValue::from_bytes` accepts + // any bytes in the 0x20..=0xFF range plus TAB, so we use a raw + // byte sequence that includes characters outside the visible ASCII + // range accepted by `to_str` (which requires only 0x20..=0x7E + // plus TAB). + let raw_val = + reqwest::header::HeaderValue::from_bytes(&[0x80, 0xAB, 0xFF]).unwrap(); + headers.insert("x-binary", raw_val); + + write_http_preamble( + &mut buf, + reqwest::Version::HTTP_11, + reqwest::StatusCode::OK, + &headers, + ) + .unwrap(); + + let output = String::from_utf8(buf).unwrap(); + assert!( + output.contains("x-binary: \r\n"), + "non-UTF8 header value should fall back to , got: {output}" + ); +} + +#[test] +fn write_http_preamble_duplicate_headers() { + let mut buf = Vec::new(); + let mut headers = reqwest::header::HeaderMap::new(); + headers.append("set-cookie", "a=1".parse().unwrap()); + headers.append("set-cookie", "b=2".parse().unwrap()); + + write_http_preamble( + &mut buf, + reqwest::Version::HTTP_11, + reqwest::StatusCode::OK, + &headers, + ) + .unwrap(); + + let output = String::from_utf8(buf).unwrap(); + assert!( + output.contains("set-cookie: a=1\r\n"), + "should include first set-cookie value, got: {output}" + ); + assert!( + output.contains("set-cookie: b=2\r\n"), + "should include second set-cookie value, got: {output}" + ); +} + +// ── Global Parameter Injection Tests ────────────────────────── + +#[test] +fn test_global_param_header_injection() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "GET".to_string(), + path: "things".to_string(), + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "api-version".to_string(), + location: GlobalParameterLocation::Header, + target: "X-Api-Version".to_string(), + value: "2024-01-01".to_string(), + }]; + let input = + parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &global_params) + .unwrap(); + assert_eq!(input.header_params.len(), 1); + assert_eq!(input.header_params[0].0, "X-Api-Version"); + assert_eq!(input.header_params[0].1, "2024-01-01"); +} + +#[test] +fn test_global_param_header_per_op_override_suppresses() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{ + GlobalParameterLocation, MethodParameter, RestDescription, RestMethod, + }; + + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "X-Api-Version".to_string(), + MethodParameter { + location: Some("header".to_string()), + ..Default::default() + }, + ); + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "GET".to_string(), + path: "things".to_string(), + parameters, + ..Default::default() + }; + let params_json = r#"{"X-Api-Version": "per-op-v3"}"#; + let global_params = vec![ResolvedGlobalParam { + name: "api-version".to_string(), + location: GlobalParameterLocation::Header, + target: "X-Api-Version".to_string(), + value: "global-v1".to_string(), + }]; + let input = parse_and_validate_inputs( + &doc, + &method, + Some(params_json), + None, + false, + None, + &[], + &global_params, + ) + .unwrap(); + assert_eq!(input.header_params.len(), 1); + assert_eq!( + input.header_params[0].1, "per-op-v3", + "per-op value should win over global" + ); +} + +#[test] +fn test_global_param_query_injection() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "GET".to_string(), + path: "things".to_string(), + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "api-version".to_string(), + location: GlobalParameterLocation::Query, + target: "api_version".to_string(), + value: "2024-01-01".to_string(), + }]; + let input = + parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &global_params) + .unwrap(); + assert!( + input.query_params.iter().any(|(k, v)| k == "api_version" && v == "2024-01-01"), + "query param should appear in query_params: {:?}", + input.query_params + ); +} + +#[test] +fn test_global_param_body_injection() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "currency".to_string(), + location: GlobalParameterLocation::Body, + target: "currency".to_string(), + value: "USD".to_string(), + }]; + let input = + parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &global_params) + .unwrap(); + let body = input.body.expect("body should be populated from global param"); + assert_eq!(body["currency"], "USD"); +} + +#[test] +fn test_global_param_nested_body_injection_when_absent() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + // A nested (dotted) body target is created when the user did not + // supply it. + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "POST".to_string(), + path: "search".to_string(), + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "currency".to_string(), + location: GlobalParameterLocation::Body, + target: "config.currency".to_string(), + value: "USD".to_string(), + }]; + let input = parse_and_validate_inputs( + &doc, + &method, + None, + Some(r#"{"query":"shoes"}"#), + false, + None, + &[], + &global_params, + ) + .unwrap(); + let body = input.body.expect("body should carry the injected nested global"); + assert_eq!(body["config"]["currency"], "USD"); + assert_eq!(body["query"], "shoes"); +} + +#[test] +fn test_global_param_nested_body_does_not_clobber_user_value() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + // Regression (FER-11190): a body global with a nested target like + // `config.currency` must NOT overwrite a value the user supplied at + // that same nested path via `--json`. A flat `contains_key("config. + // currency")` check misses the nested key and used to clobber it. + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "POST".to_string(), + path: "search".to_string(), + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "currency".to_string(), + location: GlobalParameterLocation::Body, + target: "config.currency".to_string(), + value: "USD".to_string(), + }]; + let input = parse_and_validate_inputs( + &doc, + &method, + None, + Some(r#"{"config":{"currency":"EUR"}}"#), + false, + None, + &[], + &global_params, + ) + .unwrap(); + let body = input.body.expect("body should be present"); + assert_eq!( + body["config"]["currency"], "EUR", + "user-supplied nested value must win over the global default" + ); +} + +#[test] +fn test_global_param_path_injection() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + // Path param supplied by global param only (not in method.parameters), + // so no required-param validation fires before injection. + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "GET".to_string(), + path: "orgs/{orgId}/users".to_string(), + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "org".to_string(), + location: GlobalParameterLocation::Path, + target: "orgId".to_string(), + value: "my-org-123".to_string(), + }]; + let input = + parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &global_params) + .unwrap(); + assert!( + input.full_url.contains("my-org-123"), + "path param should be substituted in URL: {}", + input.full_url + ); + assert!( + !input.full_url.contains("{orgId}"), + "template variable should be replaced: {}", + input.full_url + ); +} + +#[test] +fn test_global_param_path_injection_satisfies_declared_required_param() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{ + GlobalParameterLocation, MethodParameter, RestDescription, RestMethod, + }; + + // Regression (FER-11190): the path template variable is ALSO declared + // as a required `location: path` parameter (as OpenAPI requires). The + // required-param validation must not reject the request when a resolved + // global parameter targets that same variable — its value is injected + // right after validation. + let mut parameters = std::collections::HashMap::new(); + parameters.insert( + "regionId".to_string(), + MethodParameter { + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "GET".to_string(), + path: "regions/{regionId}/items".to_string(), + parameter_order: vec!["regionId".to_string()], + parameters, + ..Default::default() + }; + let global_params = vec![ResolvedGlobalParam { + name: "region".to_string(), + location: GlobalParameterLocation::Path, + target: "regionId".to_string(), + value: "us".to_string(), + }]; + let input = + parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &global_params) + .expect("resolved global must satisfy the declared required path param"); + assert!( + input.full_url.contains("regions/us/items"), + "path param should be substituted from the global value: {}", + input.full_url + ); + assert!( + !input.full_url.contains("{regionId}"), + "template variable should be replaced: {}", + input.full_url + ); +} + +#[test] +fn test_global_param_multiple_locations() { + use crate::openapi::app::ResolvedGlobalParam; + use crate::openapi::discovery::{GlobalParameterLocation, RestDescription, RestMethod}; + + let doc = RestDescription { + base_url: Some("https://api.example.com/".to_string()), + ..Default::default() + }; + let method = RestMethod { + http_method: "POST".to_string(), + path: "things".to_string(), + ..Default::default() + }; + let global_params = vec![ + ResolvedGlobalParam { + name: "tenant".to_string(), + location: GlobalParameterLocation::Header, + target: "X-Tenant".to_string(), + value: "acme".to_string(), + }, + ResolvedGlobalParam { + name: "version".to_string(), + location: GlobalParameterLocation::Query, + target: "version".to_string(), + value: "v2".to_string(), + }, + ResolvedGlobalParam { + name: "currency".to_string(), + location: GlobalParameterLocation::Body, + target: "currency".to_string(), + value: "EUR".to_string(), + }, + ]; + let input = + parse_and_validate_inputs(&doc, &method, None, None, false, None, &[], &global_params) + .unwrap(); + assert_eq!(input.header_params.len(), 1); + assert_eq!(input.header_params[0].0, "X-Tenant"); + assert!( + input.query_params.iter().any(|(k, v)| k == "version" && v == "v2"), + "query param should appear in query_params: {:?}", + input.query_params + ); + let body = input.body.expect("body should have currency"); + assert_eq!(body["currency"], "EUR"); +} diff --git a/src/openapi/help.rs b/src/openapi/help.rs new file mode 100644 index 0000000..b3ed069 --- /dev/null +++ b/src/openapi/help.rs @@ -0,0 +1,2010 @@ +//! Spec output — renders the CLI's command surface as a machine-readable +//! JSON document. Backs the `--schema` global flag, which is the agent-facing +//! counterpart to `--help`: wherever a user could type `--help` for prose, +//! they can type `--schema` for the same scope rendered as JSON. +//! +//! See [ADR-0006](../../../docs/adr/0006-schema-flag-agent-contract.md) for +//! the design contract this renderer implements. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde_json::{json, Map, Value}; + +use crate::openapi::discovery::{ + JsonSchema, JsonSchemaProperty, PaginationConfig, RestDescription, RestMethod, RestResource, + StreamingConfig, +}; + +/// Build the spec document for the given subcommand path. +/// +/// Returns `Some(value)` when the path resolves in this doc and `None` when it +/// doesn't (so a multi-binding caller can try the next binding). Empty path +/// always returns `Some(_)` — every binding contributes its full operation +/// list to the aggregate root view. +pub(crate) fn build_schema(doc: &RestDescription, path: &[String]) -> Option { + match path.len() { + 0 => Some(list_all_operations(doc)), + 1 => list_resource_operations(doc, &path[0]), + _ => { + // Try treating last element as a method name first. + // If that fails, the full path may resolve to a nested sub-resource — list its ops. + let resource_path: Vec<&str> = + path[..path.len() - 1].iter().map(|s| s.as_str()).collect(); + let method_name = path[path.len() - 1].as_str(); + operation_schema(doc, &resource_path, method_name).or_else(|| { + let full_path: Vec<&str> = path.iter().map(|s| s.as_str()).collect(); + list_nested_resource_operations(doc, &full_path) + }) + } + } +} + +fn list_all_operations(doc: &RestDescription) -> Value { + let mut ops: Vec = Vec::new(); + let mut names: Vec<_> = doc.resources.keys().collect(); + names.sort(); + for name in names { + collect_resource_ops(&doc.resources[name], &[name], &mut ops); + } + // Wrap with `sdkVariables` when declared; otherwise stay a bare + // array so single-binding consumers that have always seen an array + // at the root don't break on this binding alone. The empty-path + // aggregator in `app.rs` re-wraps the combined result with + // `globalFlags` per ADR-0006 — globalFlags live there because they + // describe the CLI harness, not the binding. + if doc.sdk_variables.is_empty() { + json!(ops) + } else { + json!({ + "sdkVariables": render_sdk_variables(&doc.sdk_variables), + "operations": ops, + }) + } +} + +fn render_pagination(p: &PaginationConfig) -> Value { + match p { + PaginationConfig::Cursor { cursor, next_cursor, results } => json!({ + "kind": "cursor", + "cursorParam": cursor, + "nextCursorPath": next_cursor, + "resultsPath": results, + }), + PaginationConfig::Offset { offset, results, step, has_next_page } => { + let mut out = json!({ + "kind": "offset", + "offsetParam": offset, + "resultsPath": results, + }); + if let Some(step) = step { + out["stepParam"] = json!(step); + } + if let Some(p) = has_next_page { + out["hasNextPagePath"] = json!(p); + } + out + } + PaginationConfig::Uri { next_uri, results } => json!({ + "kind": "uri", + "nextUriPath": next_uri, + "resultsPath": results, + }), + PaginationConfig::Path { next_path, results } => json!({ + "kind": "path", + "nextPathPath": next_path, + "resultsPath": results, + }), + PaginationConfig::Custom { results } => json!({ + "kind": "custom", + "resultsPath": results, + }), + } +} + +fn render_streaming(s: &StreamingConfig) -> Value { + match s { + StreamingConfig::Sse { terminator } => { + let mut out = json!({ "format": "sse" }); + if let Some(t) = terminator { + out["terminator"] = json!(t); + } + out + } + StreamingConfig::Json { terminator } => { + let mut out = json!({ "format": "json" }); + if let Some(t) = terminator { + out["terminator"] = json!(t); + } + out + } + StreamingConfig::Text => json!({ "format": "text" }), + } +} + +fn render_sdk_variables( + vars: &[crate::openapi::discovery::SdkVariable], +) -> Vec { + vars.iter() + .map(|v| { + json!({ + "name": v.name, + "type": v.ty, + "description": v.description.as_deref().unwrap_or(""), + "globalFlag": format!("--{}", crate::text::to_kebab_flag(&v.name)), + "envVar": crate::text::to_screaming_snake(&v.name), + }) + }) + .collect() +} + +fn list_resource_operations(doc: &RestDescription, resource: &str) -> Option { + let res = doc.resources.get(resource)?; + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, &[resource], &mut ops); + Some(json!(ops)) +} + +fn list_nested_resource_operations(doc: &RestDescription, path: &[&str]) -> Option { + let first = path.first()?; + let mut res = doc.resources.get(*first)?; + for segment in &path[1..] { + res = res.resources.get(*segment)?; + } + let mut ops: Vec = Vec::new(); + collect_resource_ops(res, path, &mut ops); + Some(json!(ops)) +} + +fn operation_schema(doc: &RestDescription, resource_path: &[&str], method_name: &str) -> Option { + let first = resource_path.first()?; + let mut res = doc.resources.get(*first)?; + for segment in &resource_path[1..] { + res = res.resources.get(*segment)?; + } + let method = res.methods.get(method_name)?; + Some(build_operation_schema(resource_path, method_name, method, &doc.schemas)) +} + +fn build_operation_schema( + resource_path: &[&str], + method_name: &str, + method: &RestMethod, + schemas: &HashMap, +) -> Value { + let mut properties: Map = Map::new(); + let mut required: Vec = Vec::new(); + + let mut param_names: Vec<_> = method.parameters.keys().collect(); + param_names.sort(); + for name in param_names { + let param = &method.parameters[name]; + let element_type = param.param_type.as_deref().unwrap_or("string"); + let mut prop = if param.scalar_or_array { + json!({ + "oneOf": [ + { "type": element_type }, + { "type": "array", "items": { "type": element_type } }, + ], + "description": param.description.as_deref().unwrap_or(""), + "location": param.location.as_deref().unwrap_or("query"), + }) + } else if param.repeated { + json!({ + "type": "array", + "items": { "type": element_type }, + "description": param.description.as_deref().unwrap_or(""), + "location": param.location.as_deref().unwrap_or("query"), + }) + } else { + json!({ + "type": element_type, + "description": param.description.as_deref().unwrap_or(""), + "location": param.location.as_deref().unwrap_or("query"), + }) + }; + if let Some(v) = ¶m.default_value { + prop["default"] = v.clone(); + } + if let Some(v) = ¶m.documentation_default_value { + prop["serverDefault"] = v.clone(); + } + if let Some(fmt) = ¶m.format { + prop["format"] = json!(fmt); + } + if param.nullable { + prop["nullable"] = json!(true); + } + if param.deprecated { + prop["deprecated"] = json!(true); + } + // `minimum`/`maximum` are `Option` so they emit as JSON + // numbers, matching how body-field bounds render via + // `render_property`. Guard on `is_finite()` so a pathological + // spec with NaN/±Inf doesn't emit `null` (`Number::from_f64` + // returns None for non-finite values). + if let Some(min) = param.minimum.filter(|m| m.is_finite()) { + prop["minimum"] = json!(min); + } + if let Some(max) = param.maximum.filter(|m| m.is_finite()) { + prop["maximum"] = json!(max); + } + if let Some(enums) = ¶m.enum_values { + prop["enum"] = json!(enums); + // When `x-fern-enum` overrides are present, expose the + // per-value display name and description so JSON-help + // consumers can render them without reparsing the spec. + if let Some(fern_enum) = ¶m.fern_enum { + let mut by_wire: Map = Map::new(); + for wire in enums { + if let Some(entry) = fern_enum.get(wire) { + let mut obj = Map::new(); + if let Some(name) = &entry.display_name { + obj.insert("name".to_string(), Value::String(name.clone())); + } + if let Some(desc) = &entry.description { + obj.insert("description".to_string(), Value::String(desc.clone())); + } + if !obj.is_empty() { + by_wire.insert(wire.clone(), Value::Object(obj)); + } + } + } + if !by_wire.is_empty() { + prop["x-fern-enum"] = Value::Object(by_wire); + } + } + } + if let Some(availability) = param.availability { + prop["availability"] = json!(availability.as_str()); + } + // Variable-bound path parameters are NOT per-op required flags; their + // value comes from the root-level global flag (kebab-cased) with an + // env-var fallback (SCREAMING_SNAKE_CASE), or from `--params` JSON. + // Mark them explicitly so machine consumers (LLM agents, code + // generators) know not to surface a per-op `--` flag and can + // discover the right global/env fallbacks instead. + if let Some(var_name) = param.variable_reference.as_deref() { + prop["binding"] = json!("sdk-variable"); + prop["variable"] = json!(var_name); + prop["globalFlag"] = json!(format!("--{}", crate::text::to_kebab_flag(var_name))); + prop["envVar"] = json!(crate::text::to_screaming_snake(var_name)); + } else if param.required { + required.push(name.clone()); + } + properties.insert(name.clone(), prop); + } + required.sort(); + + // Per ADR-0006: `--schema` is the agent-facing contract. Drop HTTP + // plumbing (`httpMethod`, `path`) — agents drive the CLI, not raw + // HTTP. Rename `parameters` → `input` to sidestep OpenAPI's narrow + // meaning (which excludes body fields) and pair symmetrically with + // `output`. + let mut output = json!({ + "operation": format!("{}.{}", resource_path.join("."), method_name), + "description": method.description.as_deref().unwrap_or(""), + "input": { + "type": "object", + "properties": properties, + "required": required, + }, + }); + if let Some(availability) = method.availability { + output["availability"] = json!(availability.as_str()); + } + // Per ADR-0006: surface the canonical 2xx response as `output`, with + // every `$ref` followed and inlined so the agent has a + // self-contained JSON Schema in one round-trip. Cycles break by + // emitting a `$ref` at the second encounter of the same name in a + // chain. + if let Some(response_ref) = method.response.as_ref().and_then(|r| r.schema_ref.as_deref()) { + if let Some(rendered) = render_ref(schemas, response_ref, &mut HashSet::new()) { + output["output"] = rendered; + } + } + + // Per ADR-0006: capability hints surface CLI affordances the spec + // doesn't describe — pagination (`--page-all`), binary downloads + // (`--output PATH`), streaming. Booleans default false and are + // omitted; structured hints carry only the fields an agent needs to + // drive the affordance correctly. + if let Some(p) = &method.pagination { + output["paginable"] = render_pagination(p); + } + if method.has_binary_response { + output["binaryResponse"] = json!(true); + } + if let Some(s) = &method.streaming { + output["streaming"] = render_streaming(s); + } + + // Per ADR-0006: `input.properties` mirrors the CLI's *flag surface*, + // not the wire shape. Request body fields appear as siblings of + // query/path/header params, each tagged `location: "body"`. Body + // fields with object types carry their nested schema inline so the + // agent has everything needed to construct either an individual + // `--` (when scalar) or a `--json ''` payload (when + // nested). all_of composition is merged in the same last-branch-wins + // shape as ADR-0004's parser-side flattener (independent + // implementation, see *Architecture: Code Generation Model* in + // AGENTS.md). + if let Some(body_ref) = method.request.as_ref().and_then(|r| r.schema_ref.as_deref()) { + let mut body_props: BTreeMap = BTreeMap::new(); + let mut body_required: HashSet = HashSet::new(); + let mut visited: HashSet = HashSet::new(); + collect_body_properties(schemas, body_ref, &mut body_props, &mut body_required, &mut visited, 0); + + if !body_props.is_empty() { + let input = output["input"].as_object_mut().expect("input must be object"); + let props_map = input + .get_mut("properties") + .and_then(|v| v.as_object_mut()) + .expect("input.properties must be object"); + let mut render_visited: HashSet = HashSet::new(); + // Track which body fields survived the collision check. + // body_required entries are only propagated for these — a + // body field that lost the collision must NOT elevate its + // colliding param into `input.required`, since (a) optional + // query/path/header params would be silently upgraded to + // required, and (b) variable-bound (sdk-variable) params + // would re-enter `required` despite the deliberate + // exclusion at the per-param emission site above. + let mut surfaced_body_names: HashSet = HashSet::new(); + for (name, prop) in &body_props { + if props_map.contains_key(name) { + continue; + } + let mut rendered = render_property(schemas, prop, &mut render_visited); + if let Value::Object(map) = &mut rendered { + map.insert("location".into(), json!("body")); + } + props_map.insert(name.clone(), rendered); + surfaced_body_names.insert(name.clone()); + } + // Merge body-required names into input.required (which is + // already a sorted array of strings). Only names that + // actually surfaced as body fields contribute. + let req_array = input + .get_mut("required") + .and_then(|v| v.as_array_mut()) + .expect("input.required must be array"); + let mut merged: HashSet = req_array + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect(); + for r in body_required { + if surfaced_body_names.contains(&r) { + merged.insert(r); + } + } + let mut sorted: Vec = merged.into_iter().collect(); + sorted.sort(); + *req_array = sorted.into_iter().map(Value::String).collect(); + } + } + output +} + +/// Bound on `allOf` recursion. Matches the parser/executor safety cap +/// (`parser.rs` / `executor.rs`); the value is duplicated rather than +/// shared because help.rs is a code-generation path that must stay +/// self-contained. +const MAX_ALL_OF_DEPTH: u8 = 8; + +/// Walk a request-body schema (located via `ref_name` in `schemas`) and +/// collect its top-level property bag plus its required set, applying +/// `allOf` merge with last-branch-wins semantics (see ADR-0004). The +/// `visited` set guards against cyclic `$ref` chains; `depth` bounds +/// `allOf` recursion to match the parser's safety cap. +fn collect_body_properties( + schemas: &HashMap, + ref_name: &str, + out_props: &mut BTreeMap, + out_required: &mut HashSet, + visited: &mut HashSet, + depth: u8, +) { + if depth >= MAX_ALL_OF_DEPTH { + return; + } + if visited.contains(ref_name) { + return; + } + let Some(schema) = schemas.get(ref_name) else { + return; + }; + visited.insert(ref_name.to_string()); + merge_schema_properties(schemas, schema, out_props, out_required, visited, depth + 1); + visited.remove(ref_name); +} + +/// Walk one `allOf` branch (a `JsonSchemaProperty`) recursively. Mirrors +/// `executor.rs::walk_all_of_for_validate` so the body validator and +/// `--schema` agree on inline compositions of any depth, including +/// `allOf: [{ allOf: [{ allOf: [{$ref: Base}] }] }]`. `$ref` branches +/// delegate to `collect_body_properties`; inline branches recurse on +/// their own `all_of` before contributing their own properties + required. +fn walk_inline_branch( + schemas: &HashMap, + branch: &JsonSchemaProperty, + out_props: &mut BTreeMap, + out_required: &mut HashSet, + visited: &mut HashSet, + depth: u8, +) { + if depth >= MAX_ALL_OF_DEPTH { + return; + } + if let Some(ref_path) = branch.schema_ref.as_deref() { + collect_body_properties(schemas, ref_path, out_props, out_required, visited, depth + 1); + return; + } + for nested in &branch.all_of { + walk_inline_branch(schemas, nested, out_props, out_required, visited, depth + 1); + } + for (name, prop) in &branch.properties { + out_props.insert(name.clone(), prop.clone()); + } + for r in &branch.required { + out_required.insert(r.clone()); + } +} + +fn merge_schema_properties( + schemas: &HashMap, + schema: &JsonSchema, + out_props: &mut BTreeMap, + out_required: &mut HashSet, + visited: &mut HashSet, + depth: u8, +) { + if depth >= MAX_ALL_OF_DEPTH { + return; + } + // Branches first (in declaration order); schema's own properties + // last so they act as final overlay — matches ADR-0004 last-branch- + // wins. + for branch in &schema.all_of { + walk_inline_branch(schemas, branch, out_props, out_required, visited, depth + 1); + } + for (name, prop) in &schema.properties { + out_props.insert(name.clone(), prop.clone()); + } + for r in &schema.required { + out_required.insert(r.clone()); + } +} + +/// Inline the schema named `ref_name` from `schemas`, following nested +/// `$ref`s recursively. `visited` is a stack of names currently being +/// expanded — re-encountering a name in `visited` emits a `{"$ref": name}` +/// node to break the cycle. Returns `None` only when the top-level ref +/// itself is unresolved (so the caller can omit the field). +fn render_ref( + schemas: &HashMap, + ref_name: &str, + visited: &mut HashSet, +) -> Option { + if visited.contains(ref_name) { + return Some(json!({ "$ref": ref_name })); + } + let schema = schemas.get(ref_name)?; + visited.insert(ref_name.to_string()); + let rendered = render_json_schema(schemas, schema, visited); + visited.remove(ref_name); + Some(rendered) +} + +fn render_json_schema( + schemas: &HashMap, + schema: &JsonSchema, + visited: &mut HashSet, +) -> Value { + if let Some(ref_name) = schema.schema_ref.as_deref() { + if let Some(rendered) = render_ref(schemas, ref_name, visited) { + return rendered; + } + } + let mut out = Map::new(); + if let Some(ty) = &schema.schema_type { + out.insert("type".into(), json!(ty)); + } + if schema.nullable { + out.insert("nullable".into(), json!(true)); + } + if let Some(desc) = &schema.description { + out.insert("description".into(), json!(desc)); + } + if !schema.properties.is_empty() { + let mut props: BTreeMap = BTreeMap::new(); + for (name, prop) in &schema.properties { + props.insert(name.clone(), render_property(schemas, prop, visited)); + } + out.insert("properties".into(), json!(props)); + } + if !schema.required.is_empty() { + let mut req = schema.required.clone(); + req.sort(); + out.insert("required".into(), json!(req)); + } + if let Some(items) = &schema.items { + out.insert("items".into(), render_property(schemas, items, visited)); + } + if !schema.one_of.is_empty() { + out.insert( + "oneOf".into(), + json!(schema.one_of.iter().map(|p| render_property(schemas, p, visited)).collect::>()), + ); + } + if !schema.any_of.is_empty() { + out.insert( + "anyOf".into(), + json!(schema.any_of.iter().map(|p| render_property(schemas, p, visited)).collect::>()), + ); + } + if !schema.all_of.is_empty() { + out.insert( + "allOf".into(), + json!(schema.all_of.iter().map(|p| render_property(schemas, p, visited)).collect::>()), + ); + } + if let Some(ap) = &schema.additional_properties { + out.insert("additionalProperties".into(), render_property(schemas, ap, visited)); + } + Value::Object(out) +} + +fn render_property( + schemas: &HashMap, + prop: &JsonSchemaProperty, + visited: &mut HashSet, +) -> Value { + if let Some(ref_name) = prop.schema_ref.as_deref() { + if let Some(rendered) = render_ref(schemas, ref_name, visited) { + return rendered; + } + } + let mut out = Map::new(); + if let Some(ty) = &prop.prop_type { + out.insert("type".into(), json!(ty)); + } + if prop.nullable { + out.insert("nullable".into(), json!(true)); + } + if let Some(desc) = &prop.description { + out.insert("description".into(), json!(desc)); + } + if let Some(fmt) = &prop.format { + out.insert("format".into(), json!(fmt)); + } + // OpenAPI `default:` is the server-side documentation hint, so it + // emits under `serverDefault`. Body fields have no x-fern-default. + if let Some(default) = &prop.default { + out.insert("serverDefault".into(), default.clone()); + } + if let Some(enums) = &prop.enum_values { + out.insert("enum".into(), json!(enums)); + } + if let Some(items) = &prop.items { + out.insert("items".into(), render_property(schemas, items, visited)); + } + // Nested object properties carry their own `required` list (lowered + // by the parser into JsonSchemaProperty.required). Emit it so an + // agent constructing `--json` payloads for nested objects knows + // which sub-fields the spec mandates. Skip when empty / for + // non-object properties. + if !prop.required.is_empty() { + let mut req = prop.required.clone(); + req.sort(); + out.insert("required".into(), json!(req)); + } + if !prop.properties.is_empty() { + let mut props: BTreeMap = BTreeMap::new(); + for (name, inner) in &prop.properties { + props.insert(name.clone(), render_property(schemas, inner, visited)); + } + out.insert("properties".into(), json!(props)); + } + // Bound fields are `Option` and `serde_json::Number::from_f64` + // returns None for NaN/±Inf — without the `is_finite()` gate, a + // pathological spec with `minimum: .nan` would silently emit + // `"minimum": null`, indistinguishable from a deliberate null. Skip + // non-finite values entirely; they have no JSON representation. + if let Some(m) = prop.minimum.filter(|m| m.is_finite()) { + out.insert("minimum".into(), json!(m)); + } + if let Some(m) = prop.maximum.filter(|m| m.is_finite()) { + out.insert("maximum".into(), json!(m)); + } + if let Some(m) = prop.exclusive_minimum.filter(|m| m.is_finite()) { + out.insert("exclusiveMinimum".into(), json!(m)); + } + if let Some(m) = prop.exclusive_maximum.filter(|m| m.is_finite()) { + out.insert("exclusiveMaximum".into(), json!(m)); + } + if prop.read_only { + out.insert("readOnly".into(), json!(true)); + } + // OpenAPI `example` / `examples` arrive as raw YAML; surface them as + // JSON so the agent has concrete templates without re-parsing. + if let Some(ex) = &prop.example { + if let Ok(v) = serde_json::to_value(ex) { + out.insert("example".into(), v); + } + } + if let Some(exs) = &prop.examples { + if let Ok(v) = serde_json::to_value(exs) { + out.insert("examples".into(), v); + } + } + if !prop.one_of.is_empty() { + out.insert( + "oneOf".into(), + json!(prop.one_of.iter().map(|p| render_property(schemas, p, visited)).collect::>()), + ); + } + if !prop.any_of.is_empty() { + out.insert( + "anyOf".into(), + json!(prop.any_of.iter().map(|p| render_property(schemas, p, visited)).collect::>()), + ); + } + if !prop.all_of.is_empty() { + out.insert( + "allOf".into(), + json!(prop.all_of.iter().map(|p| render_property(schemas, p, visited)).collect::>()), + ); + } + if let Some(ap) = &prop.additional_properties { + out.insert("additionalProperties".into(), render_property(schemas, ap, visited)); + } + Value::Object(out) +} + +fn collect_resource_ops(res: &RestResource, path: &[&str], ops: &mut Vec) { + let mut method_names: Vec<_> = res.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let m = &res.methods[method_name]; + // Per ADR-0006: drop `httpMethod` and `path` from listings — + // they're HTTP-execution detail an agent driving the CLI never + // uses. Agents pick by `operation` + `description`. + let mut entry = json!({ + "operation": format!("{}.{}", path.join("."), method_name), + "description": m.description.as_deref().unwrap_or(""), + }); + if let Some(availability) = m.availability { + entry["availability"] = json!(availability.as_str()); + } + ops.push(entry); + } + let mut sub_names: Vec<_> = res.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let mut sub_path = path.to_vec(); + sub_path.push(sub_name); + collect_resource_ops(&res.resources[sub_name], &sub_path, ops); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openapi::discovery::{MethodParameter, RestMethod, RestResource}; + use std::collections::HashMap; + + fn make_doc() -> RestDescription { + let mut params = HashMap::new(); + params.insert( + "user_id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("The user ID".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod { + http_method: "GET".to_string(), + path: "/users/{user_id}".to_string(), + description: Some("Get a user".to_string()), + parameters: params, + ..Default::default() + }, + ); + let mut resources = HashMap::new(); + resources.insert( + "users".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + } + } + + #[test] + fn test_render_root_lists_all() { + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_resource() { + let doc = make_doc(); + let output = list_resource_operations(&doc, "users").unwrap(); + let arr = output.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["operation"], "users.get"); + } + + #[test] + fn test_render_operation_schema() { + let doc = make_doc(); + let schema = operation_schema(&doc, &["users"], "get").unwrap(); + // Per ADR-0006: `httpMethod` and `path` are dropped from the + // per-op envelope, `parameters` is renamed to `input`. + assert!(schema.get("httpMethod").is_none(), "httpMethod should be dropped"); + assert!(schema.get("path").is_none(), "path should be dropped"); + assert!(schema.get("parameters").is_none(), "`parameters` should be renamed to `input`"); + let required = schema["input"]["required"].as_array().unwrap(); + assert!(required.iter().any(|v| v == "user_id")); + } + + #[test] + fn test_root_listing_drops_http_method_and_path() { + // Per ADR-0006: listings expose `operation` + `description` only. + // HTTP-plumbing fields are agent-irrelevant noise. + let doc = make_doc(); + let output = list_all_operations(&doc); + let arr = output.as_array().unwrap(); + assert!(!arr.is_empty()); + for op in arr { + assert!(op.get("httpMethod").is_none(), "httpMethod must be dropped from listings"); + assert!(op.get("path").is_none(), "path must be dropped from listings"); + assert!(op["operation"].is_string()); + assert!(op["description"].is_string()); + } + } + + #[test] + fn test_variable_bound_param_annotated_and_not_required_in_per_op_schema() { + // The --schema output is the machine-readable contract for LLM agents. A + // variable-bound path parameter must NOT appear in the per-op + // `required` array (there is no per-op flag for it), and the + // property MUST carry enough metadata for an agent to resolve it + // via the root-level global flag, env var, or --params JSON. + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Tenant id".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + // A plain (non-variable-bound) required path param on the same op + // MUST still show up in `required` as before. + params.insert( + "zoneId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Zone id".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/gardens/{gardenId}/zones/{zoneId}".to_string(), + description: Some("List zones".to_string()), + parameters: params, + ..Default::default() + }; + let schema = build_operation_schema(&["zones"], "get", &method, &HashMap::new()); + let required = schema["input"]["required"].as_array().unwrap(); + assert!( + !required.iter().any(|v| v == "gardenId"), + "variable-bound param must not appear in per-op `required`, got: {required:?}", + ); + assert!( + required.iter().any(|v| v == "zoneId"), + "plain required path param must still be in `required`, got: {required:?}", + ); + + let garden = &schema["input"]["properties"]["gardenId"]; + assert_eq!(garden["binding"], "sdk-variable"); + assert_eq!(garden["variable"], "gardenId"); + assert_eq!(garden["globalFlag"], "--garden-id"); + assert_eq!(garden["envVar"], "GARDEN_ID"); + } + + #[test] + fn test_root_listing_surfaces_sdk_variables_when_declared() { + // With at least one `x-fern-sdk-variables` entry the root JSON + // help wraps the operations array in an object that exposes the + // variable definitions (name, type, description, derived flag, + // env var) so machine consumers can discover the root-level + // globals without scanning every operation. + let mut doc = make_doc(); + doc.sdk_variables = vec![crate::openapi::discovery::SdkVariable { + name: "gardenId".to_string(), + ty: "string".to_string(), + description: Some("Tenant id".to_string()), + }]; + let output = list_all_operations(&doc); + let obj = output.as_object().expect("expected wrapped object when sdk_variables present"); + let vars = obj["sdkVariables"].as_array().unwrap(); + assert_eq!(vars.len(), 1); + assert_eq!(vars[0]["name"], "gardenId"); + assert_eq!(vars[0]["globalFlag"], "--garden-id"); + assert_eq!(vars[0]["envVar"], "GARDEN_ID"); + assert_eq!(vars[0]["description"], "Tenant id"); + assert!( + obj["operations"].as_array().unwrap().iter().any(|op| op["operation"] == "users.get"), + "operations array must still list every op when wrapped", + ); + } + + #[test] + fn test_binding_root_stays_bare_array_when_no_sdk_variables() { + // The binding-level `list_all_operations` stays a bare array + // when no sdkVariables are declared — the empty-path + // aggregator in `app.rs` is responsible for the final + // `{globalFlags, ...operations}` wrap that an agent sees at + // the CLI surface. Keeping the binding output narrow + // simplifies multi-binding aggregation. + let doc = make_doc(); + let output = list_all_operations(&doc); + assert!( + output.is_array(), + "binding `list_all_operations` should stay bare array when no sdkVariables", + ); + } + + #[test] + fn test_render_schema_nested_sub_resource_listing() { + // path.len() == 2 where last element is a sub-resource, not a method + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/organizations/{id}/memberships/{mid}".to_string(), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into()]; + let result = build_schema(&doc, &path); + assert!(result.is_some(), "sub-resource path should list operations, not be None"); + } + + #[test] + fn test_render_nested_operation_schema() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/organizations/{org_id}/memberships/{membership_id}".to_string(), + description: Some("Get a membership".to_string()), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let schema = operation_schema(&doc, &["organizations", "memberships"], "get-membership").unwrap(); + assert_eq!(schema["operation"], "organizations.memberships.get-membership"); + } + + #[test] + fn test_output_inlines_response_schema() { + // Per ADR-0006: `output` is the fully-inlined JSON Schema of the + // canonical 2xx response. The renderer dereferences the response + // SchemaRef against doc.schemas and embeds the result inline. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut user_props = HashMap::new(); + user_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + description: Some("User id".to_string()), + ..Default::default() + }, + ); + let user_schema = JsonSchema { + schema_type: Some("object".to_string()), + properties: user_props, + required: vec!["id".to_string()], + ..Default::default() + }; + let mut schemas = HashMap::new(); + schemas.insert("User".to_string(), user_schema); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "/user".to_string(), + response: Some(SchemaRef { + schema_ref: Some("User".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "get", &method, &schemas); + let output = &schema["output"]; + assert_eq!(output["type"], "object"); + assert_eq!(output["properties"]["id"]["type"], "string"); + assert_eq!(output["required"][0], "id"); + } + + #[test] + fn test_output_cycle_detection_emits_ref_pointer_on_self_reference() { + // Per ADR-0006: cycles in the response schema (e.g. User.manager: + // User) must not produce infinite expansion. When the renderer + // re-encounters a ref currently in its expansion stack, it emits + // {"$ref": name} to break the loop. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut user_props = HashMap::new(); + user_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + user_props.insert( + "manager".to_string(), + JsonSchemaProperty { + schema_ref: Some("User".to_string()), + ..Default::default() + }, + ); + let user_schema = JsonSchema { + schema_type: Some("object".to_string()), + properties: user_props, + ..Default::default() + }; + let mut schemas = HashMap::new(); + schemas.insert("User".to_string(), user_schema); + + let method = RestMethod { + http_method: "GET".to_string(), + path: "/user".to_string(), + response: Some(SchemaRef { + schema_ref: Some("User".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "get", &method, &schemas); + let output = &schema["output"]; + // Top level is fully inlined. + assert_eq!(output["type"], "object"); + assert_eq!(output["properties"]["id"]["type"], "string"); + // The recursive `manager` slot must break with a $ref pointer, + // not infinite-loop. + assert_eq!( + output["properties"]["manager"]["$ref"], "User", + "self-referential schema should emit $ref to break the cycle: {output}", + ); + } + + #[test] + fn test_output_unresolved_ref_omits_field() { + // When the response ref points to a name not in doc.schemas, the + // renderer omits `output` rather than emitting a useless $ref the + // agent can't resolve. + use crate::openapi::discovery::SchemaRef; + let method = RestMethod { + http_method: "GET".to_string(), + path: "/user".to_string(), + response: Some(SchemaRef { + schema_ref: Some("MissingType".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "get", &method, &HashMap::new()); + assert!(schema.get("output").is_none(), "unresolved response ref should omit `output`: {schema}"); + } + + #[test] + fn test_body_fields_surface_in_input_with_location_body() { + // Per ADR-0006: body fields appear in `input.properties` as + // siblings of query/path/header params, tagged + // `location: "body"`. Required body fields union into + // input.required. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut body_props = HashMap::new(); + body_props.insert( + "name".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + description: Some("Display name".to_string()), + ..Default::default() + }, + ); + body_props.insert( + "email".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + format: Some("email".to_string()), + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "CreateUser".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + required: vec!["name".to_string()], + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/users".to_string(), + request: Some(SchemaRef { + schema_ref: Some("CreateUser".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "create", &method, &schemas); + let props = &schema["input"]["properties"]; + assert_eq!(props["name"]["location"], "body", "name should be body: {schema}"); + assert_eq!(props["email"]["location"], "body", "email should be body: {schema}"); + assert_eq!(props["email"]["format"], "email"); + let required = schema["input"]["required"].as_array().unwrap(); + assert!( + required.iter().any(|v| v == "name"), + "required body field must propagate into input.required: {required:?}", + ); + } + + #[test] + fn test_body_field_collision_keeps_query_param_wins() { + // When a body field and a query/path/header param share a name, + // the spec-declared parameter wins (existing CLI behavior). The + // body field is dropped to avoid a contradictory location tag. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, MethodParameter, SchemaRef}; + let mut body_props = HashMap::new(); + body_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "Update".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + ..Default::default() + }, + ); + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + required: true, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "PUT".to_string(), + path: "/users/{id}".to_string(), + parameters: params, + request: Some(SchemaRef { + schema_ref: Some("Update".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "update", &method, &schemas); + assert_eq!( + schema["input"]["properties"]["id"]["location"], "path", + "path param must win over colliding body field: {schema}", + ); + } + + #[test] + fn test_body_all_of_merge_surfaces_fields_from_all_branches() { + // Per ADR-0004 + ADR-0006: body schemas using `allOf` get + // flattened so every branch's fields surface as `input` + // properties — same lowering the command builder applies. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut base_props = HashMap::new(); + base_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let base = JsonSchema { + schema_type: Some("object".to_string()), + properties: base_props, + required: vec!["id".to_string()], + ..Default::default() + }; + let mut overlay_props = HashMap::new(); + overlay_props.insert( + "extra".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let composed = JsonSchema { + all_of: vec![ + // ref branch → Base + JsonSchemaProperty { + schema_ref: Some("Base".to_string()), + ..Default::default() + }, + // inline branch with `extra` + JsonSchemaProperty { + properties: overlay_props, + ..Default::default() + }, + ], + ..Default::default() + }; + let mut schemas = HashMap::new(); + schemas.insert("Base".to_string(), base); + schemas.insert("Composed".to_string(), composed); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Composed".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["things"], "create", &method, &schemas); + let props = &schema["input"]["properties"]; + assert_eq!(props["id"]["location"], "body", "Base.id should surface: {schema}"); + assert_eq!(props["extra"]["location"], "body", "overlay.extra should surface: {schema}"); + let required = schema["input"]["required"].as_array().unwrap(); + assert!( + required.iter().any(|v| v == "id"), + "Base.required must union into input.required: {required:?}", + ); + } + + #[test] + fn test_body_field_default_surfaces_under_server_default_with_wire_type_preserved() { + // Body schemas only carry OpenAPI's `default:` keyword (no + // x-fern-default for body fields), so it must emit under + // `serverDefault`, not `default`. Wire type must round-trip + // through the IR — a numeric default stays a JSON number. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut body_props = HashMap::new(); + body_props.insert( + "limit".to_string(), + JsonSchemaProperty { + prop_type: Some("integer".to_string()), + default: Some(serde_json::json!(50)), + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "ListReq".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/things/search".to_string(), + request: Some(SchemaRef { + schema_ref: Some("ListReq".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["things"], "search", &method, &schemas); + let limit = &schema["input"]["properties"]["limit"]; + assert!( + limit.get("default").is_none(), + "body field has no x-fern-default — `default` key must be absent: {schema}", + ); + assert_eq!( + limit["serverDefault"], 50, + "OpenAPI `default:` keyword must surface under `serverDefault` as a native JSON number: {schema}", + ); + } + + #[test] + fn test_nested_object_body_field_required_surfaces_per_object() { + // ADR-0006 R1 #2: a nested object body field that declares its + // own `required: [...]` must surface that array in --schema. + // Without this, agents constructing nested `--json` payloads + // would silently miss sub-required fields. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut address_props = HashMap::new(); + address_props.insert( + "street".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + address_props.insert( + "zip".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let address = JsonSchemaProperty { + prop_type: Some("object".to_string()), + properties: address_props, + required: vec!["street".to_string()], + ..Default::default() + }; + let mut body_props = HashMap::new(); + body_props.insert("address".to_string(), address); + let mut schemas = HashMap::new(); + schemas.insert( + "CreateUser".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/users".to_string(), + request: Some(SchemaRef { + schema_ref: Some("CreateUser".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "create", &method, &schemas); + let address = &schema["input"]["properties"]["address"]; + let nested_required = address["required"].as_array().expect( + "nested object body field must carry its own `required` array", + ); + assert!( + nested_required.iter().any(|v| v == "street"), + "nested required array must include `street`: {schema}", + ); + } + + #[test] + fn test_body_required_does_not_elevate_optional_colliding_query_param() { + // ADR-0006 R1 #3: a required body field whose name collides + // with an OPTIONAL query/path/header param must NOT elevate the + // surviving (param) entry to required. The body field lost the + // collision; its required-ness lost with it. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, MethodParameter, SchemaRef}; + let mut body_props = HashMap::new(); + body_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "Update".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + required: vec!["id".to_string()], + ..Default::default() + }, + ); + let mut params = HashMap::new(); + params.insert( + "id".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("query".to_string()), + required: false, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "PUT".to_string(), + path: "/users".to_string(), + parameters: params, + request: Some(SchemaRef { + schema_ref: Some("Update".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "update", &method, &schemas); + let required = schema["input"]["required"].as_array().unwrap(); + assert!( + !required.iter().any(|v| v == "id"), + "optional query param must not be elevated to required by a colliding body field: {schema}", + ); + // Sanity: the surviving param still has location: query. + assert_eq!(schema["input"]["properties"]["id"]["location"], "query"); + } + + #[test] + fn test_body_required_does_not_re_mark_variable_bound_param_required() { + // ADR-0006 R1 #3: variable-bound path params are deliberately + // excluded from `required` (see the variable_reference branch + // earlier in build_operation_schema). A colliding required body + // field must not silently re-elevate them. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, MethodParameter, SchemaRef}; + let mut params = HashMap::new(); + params.insert( + "gardenId".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + location: Some("path".to_string()), + required: true, + variable_reference: Some("gardenId".to_string()), + ..Default::default() + }, + ); + let mut body_props = HashMap::new(); + body_props.insert( + "gardenId".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "GardenPayload".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + required: vec!["gardenId".to_string()], + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/gardens/{gardenId}/things".to_string(), + parameters: params, + request: Some(SchemaRef { + schema_ref: Some("GardenPayload".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["gardens"], "create", &method, &schemas); + let required = schema["input"]["required"].as_array().unwrap(); + assert!( + !required.iter().any(|v| v == "gardenId"), + "variable-bound param must not be re-elevated to required by a colliding body field: {schema}", + ); + // Sanity: variable-bound annotations preserved. + let garden = &schema["input"]["properties"]["gardenId"]; + assert_eq!(garden["binding"], "sdk-variable"); + assert_eq!(garden["globalFlag"], "--garden-id"); + } + + #[test] + fn test_body_all_of_arbitrarily_deep_inline_chain_surfaces_all_branches() { + // Inline allOf composition of arbitrary depth must fully recurse + // so the agent contract matches the body validator. Triple-nested + // shape: outer wraps an inline that wraps an inline that carries + // a $ref(Base). All three layers contribute properties; the deepest + // $ref must still resolve. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut base_props = HashMap::new(); + base_props.insert( + "from_base".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let base = JsonSchema { + schema_type: Some("object".to_string()), + properties: base_props, + ..Default::default() + }; + // Level 3 (innermost inline): one $ref to Base + own property `z`. + let mut l3_props = HashMap::new(); + l3_props.insert( + "z".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let l3 = JsonSchemaProperty { + all_of: vec![JsonSchemaProperty { + schema_ref: Some("Base".to_string()), + ..Default::default() + }], + properties: l3_props, + ..Default::default() + }; + // Level 2: wraps level 3 + own property `y`. + let mut l2_props = HashMap::new(); + l2_props.insert( + "y".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let l2 = JsonSchemaProperty { + all_of: vec![l3], + properties: l2_props, + ..Default::default() + }; + // Level 1: the schema's only allOf entry, wraps level 2. + let composed = JsonSchema { + all_of: vec![l2], + ..Default::default() + }; + let mut schemas = HashMap::new(); + schemas.insert("Base".to_string(), base); + schemas.insert("Composed".to_string(), composed); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "/deep".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Composed".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["deep"], "create", &method, &schemas); + let props = &schema["input"]["properties"]; + for expected in ["from_base", "y", "z"] { + assert_eq!( + props[expected]["location"], "body", + "expected {expected} from triple-nested inline allOf: {schema}", + ); + } + } + + #[test] + fn test_body_all_of_inline_branch_required_propagates_into_input_required() { + // Devin review (#1): inline allOf branches carry their own + // `required: [...]` (JsonSchemaProperty.required, added in + // Round 1 #2). `merge_schema_properties` must read it so those + // names flow into `input.required`. Without this, an inline + // overlay branch like `{type:object, required:[extra], + // properties:{extra:...}}` surfaces `extra` as a property but + // doesn't mark it required. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut base_props = HashMap::new(); + base_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let base = JsonSchema { + schema_type: Some("object".to_string()), + properties: base_props, + ..Default::default() + }; + let mut overlay_props = HashMap::new(); + overlay_props.insert( + "extra".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let composed = JsonSchema { + all_of: vec![ + JsonSchemaProperty { + schema_ref: Some("Base".to_string()), + ..Default::default() + }, + // Inline overlay branch with required: [extra] on the + // branch itself. + JsonSchemaProperty { + properties: overlay_props, + required: vec!["extra".to_string()], + ..Default::default() + }, + ], + ..Default::default() + }; + let mut schemas = HashMap::new(); + schemas.insert("Base".to_string(), base); + schemas.insert("Composed".to_string(), composed); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Composed".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["things"], "create", &method, &schemas); + let required = schema["input"]["required"].as_array().unwrap(); + assert!( + required.iter().any(|v| v == "extra"), + "inline allOf branch's `required: [extra]` must propagate into input.required: {schema}", + ); + } + + #[test] + fn test_body_all_of_inline_branch_recurses_into_nested_composition() { + // ADR-0006 R1 #6: an inline allOf branch (no schema_ref of its + // own) that itself uses allOf must have its nested composition + // walked — otherwise --schema diverges from the executor's body + // validator (which DOES recurse). Mirrors + // executor.rs::walk_all_of_for_validate. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut base_props = HashMap::new(); + base_props.insert( + "id".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + let base = JsonSchema { + schema_type: Some("object".to_string()), + properties: base_props, + ..Default::default() + }; + let mut overlay_props = HashMap::new(); + overlay_props.insert( + "y".to_string(), + JsonSchemaProperty { + prop_type: Some("string".to_string()), + ..Default::default() + }, + ); + // Inline branch that ITSELF carries a nested allOf with a $ref. + let inline_branch = JsonSchemaProperty { + all_of: vec![JsonSchemaProperty { + schema_ref: Some("Base".to_string()), + ..Default::default() + }], + properties: overlay_props, + ..Default::default() + }; + let composed = JsonSchema { + all_of: vec![inline_branch], + ..Default::default() + }; + let mut schemas = HashMap::new(); + schemas.insert("Base".to_string(), base); + schemas.insert("Composed".to_string(), composed); + + let method = RestMethod { + http_method: "POST".to_string(), + path: "/things".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Composed".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["things"], "create", &method, &schemas); + let props = &schema["input"]["properties"]; + assert_eq!( + props["id"]["location"], "body", + "Base.id (via nested allOf in inline branch) must surface: {schema}", + ); + assert_eq!( + props["y"]["location"], "body", + "inline branch's own property must still surface: {schema}", + ); + } + + #[test] + fn test_nonfinite_min_max_omitted_not_emitted_as_null() { + // ADR-0006 R3 #14: NaN / ±Infinity in min/max bounds have no + // JSON representation. Without the `is_finite()` gate they + // would emit as `null`, indistinguishable from a deliberate + // JSON null. Confirm they're omitted entirely. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, SchemaRef}; + let mut body_props = HashMap::new(); + body_props.insert( + "score".to_string(), + JsonSchemaProperty { + prop_type: Some("number".to_string()), + minimum: Some(f64::NAN), + maximum: Some(f64::INFINITY), + exclusive_minimum: Some(f64::NEG_INFINITY), + exclusive_maximum: Some(0.5), // a real one should still surface + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "Body".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/scores".to_string(), + request: Some(SchemaRef { + schema_ref: Some("Body".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["scores"], "create", &method, &schemas); + let score = &schema["input"]["properties"]["score"]; + assert!(score.get("minimum").is_none(), "NaN minimum must be omitted: {schema}"); + assert!(score.get("maximum").is_none(), "+Inf maximum must be omitted: {schema}"); + assert!(score.get("exclusiveMinimum").is_none(), "-Inf exclusiveMinimum must be omitted: {schema}"); + assert_eq!( + score["exclusiveMaximum"], 0.5, + "finite bound must still surface alongside the non-finite ones being skipped: {schema}", + ); + } + + #[test] + fn test_capability_hint_binary_response() { + // Per ADR-0006: `binaryResponse: true` surfaces on ops with a + // binary 2xx so the agent knows `--output PATH` applies. + // Omitted when false to keep the JSON tight. + let method = RestMethod { + http_method: "GET".to_string(), + path: "/file".to_string(), + has_binary_response: true, + ..Default::default() + }; + let schema = build_operation_schema(&["files"], "download", &method, &HashMap::new()); + assert_eq!(schema["binaryResponse"], true, "schema: {schema}"); + + let method2 = RestMethod { + http_method: "GET".to_string(), + path: "/file".to_string(), + ..Default::default() + }; + let schema2 = build_operation_schema(&["files"], "list", &method2, &HashMap::new()); + assert!(schema2.get("binaryResponse").is_none(), "false default should be omitted: {schema2}"); + } + + #[test] + fn test_capability_hint_paginable_cursor() { + // Cursor-style pagination surfaces structured hints — the + // agent reads `cursorParam`, `nextCursorPath`, `resultsPath` + // and knows how `--page-all` will iterate. + let method = RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + pagination: Some(crate::openapi::discovery::PaginationConfig::Cursor { + cursor: "page_token".to_string(), + next_cursor: "next_page_token".to_string(), + results: "items".to_string(), + }), + ..Default::default() + }; + let schema = build_operation_schema(&["things"], "list", &method, &HashMap::new()); + let p = &schema["paginable"]; + assert_eq!(p["kind"], "cursor"); + assert_eq!(p["cursorParam"], "page_token"); + assert_eq!(p["nextCursorPath"], "next_page_token"); + assert_eq!(p["resultsPath"], "items"); + } + + #[test] + fn test_capability_hint_streaming_sse_with_terminator() { + let method = RestMethod { + http_method: "POST".to_string(), + path: "/chat".to_string(), + streaming: Some(crate::openapi::discovery::StreamingConfig::Sse { + terminator: Some("[DONE]".to_string()), + }), + ..Default::default() + }; + let schema = build_operation_schema(&["chat"], "stream", &method, &HashMap::new()); + let s = &schema["streaming"]; + assert_eq!(s["format"], "sse"); + assert_eq!(s["terminator"], "[DONE]"); + } + + #[test] + fn test_per_property_metadata_surfaces_default_format_nullable_deprecated_constraints() { + // Per ADR-0006 phase 4: every per-property field an agent needs + // to drive the CLI correctly is surfaced. `default` is the + // client-side substitution (x-fern-default); `serverDefault` is + // the documentation-only hint (OpenAPI's `default:`). + use crate::openapi::discovery::MethodParameter; + let mut params = HashMap::new(); + params.insert( + "limit".to_string(), + MethodParameter { + param_type: Some("integer".to_string()), + description: Some("Page size".to_string()), + location: Some("query".to_string()), + format: Some("int32".to_string()), + default_value: Some(serde_json::json!(50)), + documentation_default_value: Some(serde_json::json!(20)), + nullable: true, + deprecated: true, + minimum: Some(1.0), + maximum: Some(100.0), + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "GET".to_string(), + path: "/things".to_string(), + parameters: params, + ..Default::default() + }; + let schema = build_operation_schema(&["things"], "list", &method, &HashMap::new()); + let limit = &schema["input"]["properties"]["limit"]; + assert_eq!(limit["default"], 50, "client default surfaces under `default`: {schema}"); + assert_eq!(limit["serverDefault"], 20, "doc default surfaces under `serverDefault`: {schema}"); + assert_eq!(limit["format"], "int32"); + assert_eq!(limit["nullable"], true); + assert_eq!(limit["deprecated"], true); + // Param bounds emit as JSON numbers (symmetric with body-field + // bounds rendered via `render_property`). The old contract + // emitted strings here; ADR-0006 Devin #3 aligned both sides. + // f64 source preserves through serde_json as Number(1.0). + assert_eq!(limit["minimum"], 1.0); + assert_eq!(limit["maximum"], 100.0); + } + + #[test] + fn test_param_and_body_field_bounds_share_same_json_number_type() { + // Devin review (#3): a query/path/header param with the same + // numeric bounds as a body field must surface the same JSON + // type in --schema. Pre-fix, params emitted strings and bodies + // emitted numbers — agents parsing constraints uniformly got + // burned by the inconsistency. + use crate::openapi::discovery::{JsonSchema, JsonSchemaProperty, MethodParameter, SchemaRef}; + let mut params = HashMap::new(); + params.insert( + "score_param".to_string(), + MethodParameter { + param_type: Some("number".to_string()), + location: Some("query".to_string()), + minimum: Some(0.0), + maximum: Some(1.0), + ..Default::default() + }, + ); + let mut body_props = HashMap::new(); + body_props.insert( + "score_body".to_string(), + JsonSchemaProperty { + prop_type: Some("number".to_string()), + minimum: Some(0.0), + maximum: Some(1.0), + ..Default::default() + }, + ); + let mut schemas = HashMap::new(); + schemas.insert( + "Body".to_string(), + JsonSchema { + schema_type: Some("object".to_string()), + properties: body_props, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/scores".to_string(), + parameters: params, + request: Some(SchemaRef { + schema_ref: Some("Body".to_string()), + ..Default::default() + }), + ..Default::default() + }; + let schema = build_operation_schema(&["scores"], "create", &method, &schemas); + let props = &schema["input"]["properties"]; + // Both must surface as JSON numbers. + assert!(props["score_param"]["minimum"].is_number(), "param min must be JSON number: {schema}"); + assert!(props["score_body"]["minimum"].is_number(), "body min must be JSON number: {schema}"); + assert_eq!(props["score_param"]["minimum"], props["score_body"]["minimum"]); + assert_eq!(props["score_param"]["maximum"], props["score_body"]["maximum"]); + } + + #[test] + fn test_output_absent_when_method_has_no_response() { + // Operations with no declared response schema (e.g. 204) emit no + // `output` field — the agent gets the per-op envelope without it. + let method = RestMethod { + http_method: "DELETE".to_string(), + path: "/user".to_string(), + response: None, + ..Default::default() + }; + let schema = build_operation_schema(&["users"], "delete", &method, &HashMap::new()); + assert!(schema.get("output").is_none(), "no response → no `output` field: {schema}"); + } + + #[test] + fn test_render_schema_dispatches_nested_path() { + let mut nested_methods = std::collections::HashMap::new(); + nested_methods.insert( + "get-membership".to_string(), + crate::openapi::discovery::RestMethod { + http_method: "GET".to_string(), + path: "/orgs/{id}/memberships/{mid}".to_string(), + ..Default::default() + }, + ); + let mut sub_resources = std::collections::HashMap::new(); + sub_resources.insert( + "memberships".to_string(), + RestResource { + methods: nested_methods, + resources: std::collections::HashMap::new(), + }, + ); + let mut resources = std::collections::HashMap::new(); + resources.insert( + "organizations".to_string(), + RestResource { + methods: std::collections::HashMap::new(), + resources: sub_resources, + }, + ); + let doc = RestDescription { + name: "test".to_string(), + resources, + ..Default::default() + }; + + let path: Vec = vec!["organizations".into(), "memberships".into(), "get-membership".into()]; + // Should resolve as the leaf operation, not be misrouted via "memberships" as method name. + let result = build_schema(&doc, &path); + assert!(result.is_some(), "nested path should resolve correctly"); + } + + #[test] + fn test_repeated_param_rendered_as_array_in_schema() { + let mut params = HashMap::new(); + params.insert( + "tags".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Tags".to_string()), + location: Some("body".to_string()), + repeated: true, + ..Default::default() + }, + ); + params.insert( + "subject".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Subject line".to_string()), + location: Some("body".to_string()), + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/messages/send".to_string(), + parameters: params, + ..Default::default() + }; + let schema = build_operation_schema(&["messages"], "send", &method, &HashMap::new()); + let props = &schema["input"]["properties"]; + + // Pure array param: type is array with items. + assert_eq!(props["tags"]["type"], "array"); + assert_eq!(props["tags"]["items"]["type"], "string"); + + // Scalar param: plain type. + assert_eq!(props["subject"]["type"], "string"); + assert!(props["subject"]["items"].is_null()); + } + + #[test] + fn test_scalar_or_array_union_rendered_as_oneof_in_schema() { + let mut params = HashMap::new(); + params.insert( + "to".to_string(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some("Recipient addresses".to_string()), + location: Some("body".to_string()), + repeated: true, + scalar_or_array: true, + ..Default::default() + }, + ); + let method = RestMethod { + http_method: "POST".to_string(), + path: "/messages/send".to_string(), + parameters: params, + ..Default::default() + }; + let schema = build_operation_schema(&["messages"], "send", &method, &HashMap::new()); + let props = &schema["input"]["properties"]; + + // Union param: oneOf [string, array]. + assert!(props["to"]["type"].is_null(), "should not have top-level type"); + let one_of = props["to"]["oneOf"].as_array().unwrap(); + assert_eq!(one_of.len(), 2); + assert_eq!(one_of[0]["type"], "string"); + assert_eq!(one_of[1]["type"], "array"); + assert_eq!(one_of[1]["items"]["type"], "string"); + assert_eq!(props["to"]["description"], "Recipient addresses"); + } +} diff --git a/src/openapi/mod.rs b/src/openapi/mod.rs new file mode 100644 index 0000000..cdc657e --- /dev/null +++ b/src/openapi/mod.rs @@ -0,0 +1,15 @@ +mod app; +mod binding; +pub mod commands; +mod help; +pub mod executor; +pub mod overlay; +mod parser; +pub mod discovery; +pub mod skill_emitter; + +pub use self::app::{AppContext, resolve_method_from_matches}; +pub(crate) use self::app::CliApp; +pub use self::binding::OpenApiBinding; +pub use self::overlay::{apply_overlay, apply_overlays_to_spec, parse_overlay, validate_overlay}; +pub use self::parser::{deep_merge_yaml, load_openapi_spec, load_openapi_spec_from_value}; diff --git a/src/openapi/overlay.rs b/src/openapi/overlay.rs new file mode 100644 index 0000000..d5a3371 --- /dev/null +++ b/src/openapi/overlay.rs @@ -0,0 +1,1830 @@ +//! OpenAPI Overlay support (v1.0.0) +//! +//! Applies [OpenAPI Overlays](https://spec.openapis.org/overlay/latest.html) to +//! an OpenAPI document represented as a generic JSON value. Each overlay contains +//! a list of *actions* whose `target` is a JSONPath (RFC 9535) expression. Actions +//! either **update** (deep-merge) or **remove** matched nodes. + +use serde::Deserialize; +use serde_json::Value; +use serde_json_path::JsonPath; + +use crate::error::CliError; + +// --------------------------------------------------------------------------- +// Overlay document types +// --------------------------------------------------------------------------- + +/// A single overlay action targeting nodes via a JSONPath expression. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayAction { + /// JSONPath (RFC 9535) expression selecting target nodes. + pub target: String, + /// Human-readable description of the action. + #[serde(default)] + pub description: Option, + /// Value to deep-merge into each matched node. Required when `remove` is + /// false/absent. + #[serde(default)] + pub update: Option, + /// When `true`, matched nodes are removed instead of updated. + #[serde(default)] + pub remove: bool, +} + +/// Metadata block inside an overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayInfo { + pub title: String, + pub version: String, +} + +/// A complete overlay document. +#[derive(Debug, Clone, Deserialize)] +pub struct OverlayDocument { + /// Overlay specification version (e.g. `"1.0.0"`). + pub overlay: String, + /// Metadata about this overlay. + pub info: OverlayInfo, + /// Optional base document this overlay extends. + #[serde(default)] + pub extends: Option, + /// Ordered list of actions to apply. + pub actions: Vec, +} + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +/// Parse an overlay document from a YAML or JSON string. +pub fn parse_overlay(input: &str) -> Result { + // Try JSON first, then YAML + serde_json::from_str::(input) + .or_else(|_| { + let yaml_value: serde_yaml::Value = serde_yaml::from_str(input) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}")))?; + let json_value = yaml_to_json(yaml_value); + serde_json::from_value::(json_value) + .map_err(|e| CliError::Discovery(format!("Failed to parse overlay file: {e}"))) + }) + .map_err(|e| match e { + CliError::Discovery(_) => e, + _ => CliError::Discovery(format!("Failed to parse overlay file: {e}")), + }) +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/// Validate the structure of a parsed overlay document. +pub fn validate_overlay(overlay: &OverlayDocument) -> Result<(), CliError> { + if overlay.overlay.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'overlay' version field".to_string(), + )); + } + + if overlay.info.title.is_empty() || overlay.info.version.is_empty() { + return Err(CliError::Validation( + "Overlay file missing required 'info.title' or 'info.version' field".to_string(), + )); + } + + if overlay.actions.is_empty() { + return Err(CliError::Validation( + "Overlay file must have at least one action".to_string(), + )); + } + + for (i, action) in overlay.actions.iter().enumerate() { + if action.target.is_empty() { + return Err(CliError::Validation(format!( + "Overlay action at index {i} missing required 'target' field" + ))); + } + if action.update.is_none() && !action.remove { + return Err(CliError::Validation(format!( + "Overlay action at index {i} must have either 'update' or 'remove'" + ))); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Application +// --------------------------------------------------------------------------- + +/// Apply an overlay document to an OpenAPI spec represented as a JSON value. +/// +/// Actions are applied sequentially; each one operates on the result of the +/// previous action. This function does **not** mutate the input — it returns a +/// new value. +pub fn apply_overlay(doc: &Value, overlay: &OverlayDocument) -> Result { + let mut output = doc.clone(); + + for (i, action) in overlay.actions.iter().enumerate() { + let path = JsonPath::parse(&action.target).map_err(|e| { + CliError::Validation(format!( + "Invalid JSONPath in overlay action {i} (target: '{}'): {e}", + action.target + )) + })?; + + if action.remove { + apply_remove(&mut output, &path); + } else if let Some(ref update) = action.update { + apply_update(&mut output, &path, update)?; + } + } + + Ok(output) +} + +/// Apply a remove action: delete all nodes matched by `path`. +fn apply_remove(doc: &mut Value, path: &JsonPath) { + let located = path.query_located(doc); + // Collect normalized paths; process in reverse so array indices stay valid + let mut paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + paths.sort_by(|a, b| b.cmp(a)); + + for segments in &paths { + remove_at_path(doc, segments); + } +} + +/// Apply an update (deep-merge) action to all nodes matched by `path`. +fn apply_update(doc: &mut Value, path: &JsonPath, update: &Value) -> Result<(), CliError> { + let located = path.query_located(doc); + let paths: Vec> = located + .iter() + .map(|node| normalized_path_to_segments(node.location())) + .collect(); + + if paths.is_empty() { + return Ok(()); + } + + for segments in &paths { + if segments.is_empty() { + // Root target — merge directly into doc + if let Value::Object(_) = update { + deep_merge(doc, update); + } + } else { + merge_at_path(doc, segments, update); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Path navigation helpers +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum PathSegment { + Key(String), + Index(usize), +} + +/// Convert a `serde_json_path` `NormalizedPath` location into our own segment list. +fn normalized_path_to_segments( + location: &serde_json_path::NormalizedPath<'_>, +) -> Vec { + location + .iter() + .filter_map(|elem| { + if let Some(name) = elem.as_name() { + Some(PathSegment::Key(name.to_string())) + } else { + elem.as_index().map(PathSegment::Index) + } + }) + .collect() +} + + +/// Navigate to a path's parent and remove the target node. +fn remove_at_path(doc: &mut Value, segments: &[PathSegment]) { + if segments.is_empty() { + return; + } + + let (parent_segments, last) = segments.split_at(segments.len() - 1); + let last = &last[0]; + + let parent = navigate_to_mut(doc, parent_segments); + let Some(parent) = parent else { return }; + + match last { + PathSegment::Key(key) => { + if let Value::Object(map) = parent { + map.remove(key); + } + } + PathSegment::Index(idx) => { + if let Value::Array(arr) = parent { + if *idx < arr.len() { + arr.remove(*idx); + } + } + } + } +} + +/// Navigate to a path and deep-merge the update value. +fn merge_at_path(doc: &mut Value, segments: &[PathSegment], update: &Value) { + let target = navigate_to_mut(doc, segments); + let Some(target) = target else { return }; + + // Match Fern CLI behavior (applyOpenAPIOverlay.ts L74-77): when the target + // is an array and the update is NOT itself an array, append the value. + if let Value::Array(arr) = target { + if !update.is_array() { + arr.push(update.clone()); + return; + } + } + + deep_merge(target, update); +} + +/// Walk the JSON tree following the given segments, returning a mutable ref to +/// the target node, or `None` if the path does not exist. +fn navigate_to_mut<'a>(doc: &'a mut Value, segments: &[PathSegment]) -> Option<&'a mut Value> { + let mut current = doc; + for segment in segments { + current = match segment { + PathSegment::Key(key) => current.get_mut(key.as_str())?, + PathSegment::Index(idx) => current.get_mut(*idx)?, + }; + } + Some(current) +} + +// --------------------------------------------------------------------------- +// Deep merge +// --------------------------------------------------------------------------- + +/// Recursively merge `update` into `base`, matching lodash `merge` semantics. +/// +/// - Objects are merged key-by-key (recursive). +/// - Arrays are merged index-by-index: each element in `update` is deep-merged +/// into the corresponding index of `base`. If `update` is shorter, trailing +/// `base` elements are preserved. If `update` is longer, new elements are +/// appended. +/// - All other types are overwritten. +pub fn deep_merge(base: &mut Value, update: &Value) { + match (base, update) { + (Value::Object(base_map), Value::Object(update_map)) => { + for (key, update_val) in update_map { + let entry = base_map + .entry(key.clone()) + .or_insert(Value::Null); + deep_merge(entry, update_val); + } + } + (Value::Array(base_arr), Value::Array(update_arr)) => { + for (i, update_val) in update_arr.iter().enumerate() { + if i < base_arr.len() { + deep_merge(&mut base_arr[i], update_val); + } else { + base_arr.push(update_val.clone()); + } + } + } + (base, update) => { + *base = update.clone(); + } + } +} + +// --------------------------------------------------------------------------- +// YAML → JSON conversion +// --------------------------------------------------------------------------- + +/// Convert a `serde_yaml::Value` into a `serde_json::Value`. +fn yaml_to_json(yaml: serde_yaml::Value) -> Value { + match yaml { + serde_yaml::Value::Null => Value::Null, + serde_yaml::Value::Bool(b) => Value::Bool(b), + serde_yaml::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i.into()) + } else if let Some(u) = n.as_u64() { + Value::Number(u.into()) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or(Value::Null) + } else { + Value::Null + } + } + serde_yaml::Value::String(s) => Value::String(s), + serde_yaml::Value::Sequence(seq) => { + Value::Array(seq.into_iter().map(yaml_to_json).collect()) + } + serde_yaml::Value::Mapping(map) => { + let obj = map + .into_iter() + .filter_map(|(k, v)| { + let key = match k { + serde_yaml::Value::String(s) => s, + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + _ => return None, + }; + Some((key, yaml_to_json(v))) + }) + .collect(); + Value::Object(obj) + } + serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value), + } +} + +/// Parse an OpenAPI spec string (YAML or JSON) into a `serde_json::Value`, +/// apply a list of overlay strings, and return the modified JSON value +/// serialised back to a YAML string suitable for `load_openapi_spec`. +pub fn apply_overlays_to_spec( + spec_yaml: &str, + overlay_strings: &[String], +) -> Result { + if overlay_strings.is_empty() { + return Ok(spec_yaml.to_string()); + } + + // Parse spec into a generic JSON value + let yaml_value: serde_yaml::Value = serde_yaml::from_str(spec_yaml) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + let mut doc = yaml_to_json(yaml_value); + + for (idx, overlay_str) in overlay_strings.iter().enumerate() { + let overlay = parse_overlay(overlay_str).map_err(|e| { + CliError::Discovery(format!("Failed to parse overlay {idx}: {e}")) + })?; + validate_overlay(&overlay).map_err(|e| { + CliError::Validation(format!("Invalid overlay {idx}: {e}")) + })?; + + tracing::debug!( + "Applying overlay \"{}\" v{}", + overlay.info.title, + overlay.info.version + ); + + doc = apply_overlay(&doc, &overlay)?; + } + + // Serialize back to YAML + serde_yaml::to_string(&doc) + .map_err(|e| CliError::Discovery(format!("Failed to serialize overlaid spec: {e}"))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // -- deep_merge -- + + #[test] + fn test_deep_merge_objects() { + let mut base = json!({"a": 1, "b": {"c": 2}}); + let update = json!({"b": {"d": 3}, "e": 4}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1, "b": {"c": 2, "d": 3}, "e": 4})); + } + + #[test] + fn test_deep_merge_overwrites_primitives() { + let mut base = json!({"a": 1}); + let update = json!({"a": 2}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 2})); + } + + #[test] + fn test_deep_merge_nested() { + let mut base = json!({"a": {"b": {"c": 1, "d": 2}}}); + let update = json!({"a": {"b": {"c": 10, "e": 3}}}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": {"b": {"c": 10, "d": 2, "e": 3}}})); + } + + // -- parse_overlay -- + + #[test] + fn test_parse_overlay_yaml() { + let yaml = r#" +overlay: "1.0.0" +info: + title: Test Overlay + version: "1.0" +actions: + - target: "$.info" + update: + description: "Updated description" +"#; + let doc = parse_overlay(yaml).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.info.title, "Test Overlay"); + assert_eq!(doc.actions.len(), 1); + } + + #[test] + fn test_parse_overlay_json() { + let json_str = r#"{ + "overlay": "1.0.0", + "info": {"title": "Test", "version": "1.0"}, + "actions": [ + {"target": "$.info", "update": {"description": "hi"}} + ] + }"#; + let doc = parse_overlay(json_str).unwrap(); + assert_eq!(doc.overlay, "1.0.0"); + assert_eq!(doc.actions.len(), 1); + } + + // -- validate_overlay -- + + #[test] + fn test_validate_overlay_missing_version() { + let doc = OverlayDocument { + overlay: String::new(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_no_actions() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_target() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: String::new(), + description: None, + update: Some(json!({})), + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + #[test] + fn test_validate_overlay_action_no_update_no_remove() { + let doc = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: None, + remove: false, + }], + }; + assert!(validate_overlay(&doc).is_err()); + } + + // -- apply_overlay: update -- + + #[test] + fn test_overlay_update_simple_path() { + let doc = json!({ + "info": {"title": "Old", "version": "1.0"}, + "paths": {} + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.info".into(), + description: None, + update: Some(json!({"title": "New", "description": "Added"})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "1.0"); + assert_eq!(result["info"]["description"], "Added"); + } + + #[test] + fn test_overlay_update_nested_path() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_object()); + } + + // -- apply_overlay: remove -- + + #[test] + fn test_overlay_remove_property() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "email": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.User.properties.email".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["User"]["properties"]["email"].is_null()); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: wildcard -- + + #[test] + fn test_overlay_wildcard_update() { + let doc = json!({ + "paths": { + "/users": { + "get": {"summary": "Get users"} + }, + "/posts": { + "get": {"summary": "Get posts"} + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths.*.get".into(), + description: None, + update: Some(json!({"security": [{"Bearer": []}]})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["paths"]["/users"]["get"]["security"].is_array()); + assert!(result["paths"]["/posts"]["get"]["security"].is_array()); + } + + // -- apply_overlay: zero matches -- + + #[test] + fn test_overlay_zero_match_no_error() { + let doc = json!({"info": {"title": "Test"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.nonexistent.path".into(), + description: None, + update: Some(json!({"x": 1})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result, doc); + } + + // -- apply_overlay: sequential actions -- + + #[test] + fn test_overlay_sequential_actions() { + let doc = json!({ + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": {"type": "string"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![ + OverlayAction { + target: "$.components.schemas.User".into(), + description: None, + update: Some(json!({ + "properties": { + "id": {"type": "string"}, + "profile": {"type": "object", "properties": {"name": {"type": "string"}}} + } + })), + remove: false, + }, + OverlayAction { + target: "$.components.schemas.User.properties.profile".into(), + description: None, + update: Some(json!({ + "properties": { + "name": {"type": "string"}, + "email": {"type": "string", "format": "email"} + } + })), + remove: false, + }, + ], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["email"]["type"], + "string" + ); + assert_eq!( + result["components"]["schemas"]["User"]["properties"]["profile"]["properties"]["name"]["type"], + "string" + ); + } + + // -- apply_overlay: root target -- + + #[test] + fn test_overlay_root_target() { + let doc = json!({"info": {"title": "Old"}}); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$".into(), + description: None, + update: Some(json!({"info": {"title": "New", "version": "2.0"}})), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["info"]["title"], "New"); + assert_eq!(result["info"]["version"], "2.0"); + } + + // -- apply_overlays_to_spec -- + + #[test] + fn test_apply_overlays_to_spec_roundtrip() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /plants: + get: + operationId: list-plants + summary: List plants + x-fern-sdk-group-name: + - plants + x-fern-sdk-method-name: list +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: Add description + version: "1.0" +actions: + - target: "$.info" + update: + description: "A plant management API" +"#; + + let result = apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + // The result should be valid YAML that can be parsed + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + let info = &parsed["info"]; + assert_eq!(info["description"], serde_yaml::Value::String("A plant management API".into())); + // Original fields preserved + assert_eq!(info["title"], serde_yaml::Value::String("Test API".into())); + } + + #[test] + fn test_apply_overlays_to_spec_no_overlays() { + let spec = "openapi: 3.0.0\ninfo:\n title: Test\n version: '1.0'\n"; + let result = apply_overlays_to_spec(spec, &[]).unwrap(); + assert_eq!(result, spec); + } + + // -- array removal -- + + #[test] + fn test_overlay_remove_array_element() { + let doc = json!({ + "paths": { + "/plants": { + "get": { + "parameters": [ + {"name": "id", "in": "query"}, + {"name": "limit", "in": "query"}, + {"name": "offset", "in": "query"} + ] + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.paths['/plants'].get.parameters[1]".into(), + description: None, + update: None, + remove: true, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params.len(), 2); + assert_eq!(params[0]["name"], "id"); + assert_eq!(params[1]["name"], "offset"); + } + + // -- multiple overlays -- + + #[test] + fn test_apply_multiple_overlays() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: {} +"#; + let overlay1 = r#" +overlay: "1.0.0" +info: + title: Overlay 1 + version: "1.0" +actions: + - target: "$.info" + update: + description: "First overlay" +"#; + let overlay2 = r#" +overlay: "1.0.0" +info: + title: Overlay 2 + version: "1.0" +actions: + - target: "$.info" + update: + contact: + name: "Plant Store Support" +"#; + let result = apply_overlays_to_spec(spec, &[overlay1.to_string(), overlay2.to_string()]).unwrap(); + let parsed: serde_yaml::Value = serde_yaml::from_str(&result).unwrap(); + assert_eq!( + parsed["info"]["description"], + serde_yaml::Value::String("First overlay".into()) + ); + assert_eq!( + parsed["info"]["contact"]["name"], + serde_yaml::Value::String("Plant Store Support".into()) + ); + } + + // -- deep merge preserves existing keys -- + + #[test] + fn test_deep_merge_preserves_existing() { + let doc = json!({ + "components": { + "schemas": { + "Plant": { + "type": "object", + "properties": { + "species": {"type": "string"}, + "height": {"type": "number"} + } + } + } + } + }); + let overlay = OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "T".into(), version: "1".into() }, + extends: None, + actions: vec![OverlayAction { + target: "$.components.schemas.Plant.properties".into(), + description: None, + update: Some(json!({ + "species": {"type": "string", "description": "The plant species"}, + "color": {"type": "string"} + })), + remove: false, + }], + }; + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["height"]["type"], "number"); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["species"]["description"], + "The plant species" + ); + assert_eq!(result["components"]["schemas"]["Plant"]["properties"]["color"]["type"], "string"); + } + + // ----------------------------------------------------------------------- + // Tests ported from Fern CLI TypeScript (applyOpenAPIOverlay.test.ts) + // These ensure behavioral parity with the Fern CLI overlay implementation. + // ----------------------------------------------------------------------- + + fn make_overlay(actions: Vec) -> OverlayDocument { + OverlayDocument { + overlay: "1.0.0".into(), + info: OverlayInfo { title: "Test".into(), version: "1.0".into() }, + extends: None, + actions, + } + } + + fn update_action(target: &str, update: Value) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: Some(update), + remove: false, + } + } + + fn remove_action(target: &str) -> OverlayAction { + OverlayAction { + target: target.into(), + description: None, + update: None, + remove: true, + } + } + + /// Port of TS: "should merge updates into a schema at a JSONPath target" + #[test] + fn test_fern_merge_updates_into_schema() { + let doc = json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.UserUpdate", + json!({ + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "UserUpdate": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "lastName": { "type": "string" }, + "email": { "type": "string", "nullable": true } + } + }}} + }) + ); + } + + /// Port of TS: "should merge arrays of objects in OpenAPI paths" + /// Uses filter expression to target a specific array element. + #[test] + fn test_fern_merge_array_element_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.name=='id')]", + json!({ "name": "id", "in": "query", "required": true, "description": "Plant ID" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true, "description": "Plant ID" }, + { "name": "limit", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should replace arrays of primitives" + /// When both target and update are arrays, lodash-style index-by-index merge. + #[test] + fn test_fern_replace_primitive_arrays() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": ["annual", "perennial"] + }} + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.tags.enum", + json!(["tropical", "succulent"]), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["tags"]["enum"], + json!(["tropical", "succulent"]) + ); + } + + /// Port of TS: "should ignore updates if remove is true" + #[test] + fn test_fern_remove_ignores_update() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" }, + "toxicity": { "type": "string" } + } + }}} + }); + let overlay = make_overlay(vec![OverlayAction { + target: "$.components.schemas.Plant.properties.toxicity".into(), + description: None, + update: Some(json!({ "type": "string", "format": "enum" })), + remove: true, + }]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result, + json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "species": { "type": "string" } + } + }}} + }) + ); + } + + /// Port of TS: "should handle multiple consecutive array removals" + #[test] + fn test_fern_multiple_consecutive_array_removals() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![ + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'limit')]"), + remove_action("$.paths['/plants'].get.parameters[?(@.name == 'offset')]"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/plants"]["get"]["parameters"], + json!([ + { "name": "id", "in": "query", "required": true }, + { "name": "sort", "in": "query", "required": false } + ]) + ); + } + + /// Port of TS: "should handle merges to multiple items in an array" + #[test] + fn test_fern_merge_multiple_array_items_by_filter() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "id", "in": "query", "required": true }, + { "name": "limit", "in": "query", "required": false }, + { "name": "authorization", "in": "header", "required": true }, + { "name": "offset", "in": "query", "required": false }, + { "name": "sort", "in": "query", "required": false } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[?(@.in == 'query')]", + json!({ "description": "Query parameter" }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = result["paths"]["/plants"]["get"]["parameters"].as_array().unwrap(); + assert_eq!(params[0]["description"], "Query parameter"); + assert_eq!(params[1]["description"], "Query parameter"); + assert!(params[2].get("description").is_none()); // header param untouched + assert_eq!(params[3]["description"], "Query parameter"); + assert_eq!(params[4]["description"], "Query parameter"); + } + + /// Port of TS: "should handle multiple overlay actions" + #[test] + fn test_fern_multiple_overlay_actions() { + let doc = json!({ + "components": { "schemas": { + "PlantUpdate": { + "type": "object", + "properties": { "species": { "type": "string" } } + }, + "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.PlantUpdate", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert!(result["components"]["schemas"]["PlantUpdate"]["properties"]["color"].is_object()); + assert!(result["components"]["schemas"]["Plant"]["properties"]["species"].is_object()); + } + + /// Port of TS: "should handle actions on items inserted by earlier actions" + #[test] + fn test_fern_actions_on_items_from_earlier_actions() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "id": { "type": "string" } } + }}} + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "habitat": { + "type": "object", + "properties": { "climate": { "type": "string" } } + } + } + }), + ), + update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { "type": "string" }, + "soil": { "type": "string", "format": "enum" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + assert!(habitat["climate"].is_object()); + assert_eq!(habitat["soil"]["format"], "enum"); + } + + /// Port of TS: "should handle wildcard path matching across multiple paths" + #[test] + fn test_fern_wildcard_across_multiple_paths() { + let doc = json!({ + "paths": { + "/plants": { + "get": { "summary": "Get plants", "operationId": "getPlants" }, + "post": { "summary": "Create plant", "operationId": "createPlant" } + }, + "/gardens": { + "get": { "summary": "Get gardens", "operationId": "getGardens" } + }, + "/nurseries": { + "get": { "summary": "Get nurseries", "operationId": "getNurseries" }, + "delete": { "summary": "Delete nursery", "operationId": "deleteNursery" } + } + } + }); + let overlay = make_overlay(vec![update_action( + "$.paths.*.get", + json!({ "security": [{ "Bearer": [] }] }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // All GET operations should have security + assert!(result["paths"]["/plants"]["get"]["security"].is_array()); + assert!(result["paths"]["/gardens"]["get"]["security"].is_array()); + assert!(result["paths"]["/nurseries"]["get"]["security"].is_array()); + // Non-GET operations should not + assert!(result["paths"]["/plants"]["post"].get("security").is_none()); + assert!(result["paths"]["/nurseries"]["delete"].get("security").is_none()); + } + + /// Port of TS: "should handle zero-match JSONPath expressions" + #[test] + fn test_fern_zero_match_continues_processing() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" } + } + }}}, + "paths": { "/plants": { "get": { "summary": "Get plants" } } } + }); + let overlay = make_overlay(vec![ + update_action( + "$.components.schemas.NonExistentSchema", + json!({ "type": "object" }), + ), + update_action( + "$.paths['/nonexistent'].post", + json!({ "summary": "Non-existent" }), + ), + update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "id": { "type": "string" }, + "species": { "type": "string" }, + "color": { "type": "string", "format": "hex" } + } + }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Only the last valid action should have taken effect + assert!(result["components"]["schemas"]["Plant"]["properties"]["color"].is_object()); + // Original data untouched where no match + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle deep merge behavior" + #[test] + fn test_fern_deep_merge_preserves_nested_structure() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "habitat": { + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "humidity": { "type": "integer" } + } + }, + "soil": { + "type": "object", + "properties": { "ph": { "type": "string" } } + } + } + }, + "care": { + "type": "object", + "properties": { + "watering": { "type": "string", "default": "weekly" } + } + } + } + }}} + }); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant.properties.habitat", + json!({ + "type": "object", + "properties": { + "climate": { + "type": "object", + "properties": { + "temperature": { "type": "string" }, + "rainfall": { "type": "string" } + } + }, + "soil": { + "type": "object", + "properties": { + "ph": { "type": "string" }, + "drainage": { "type": "string", "format": "enum" } + } + }, + "sunlight": { + "type": "object", + "properties": { + "hours": { "type": "integer", "default": 6 } + } + } + } + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let habitat = &result["components"]["schemas"]["Plant"]["properties"]["habitat"]["properties"]; + // Existing humidity preserved + assert_eq!(habitat["climate"]["properties"]["humidity"]["type"], "integer"); + // New rainfall added + assert_eq!(habitat["soil"]["properties"]["drainage"]["format"], "enum"); + // New sunlight section added + assert_eq!(habitat["sunlight"]["properties"]["hours"]["default"], 6); + // care section untouched + assert_eq!( + result["components"]["schemas"]["Plant"]["properties"]["care"]["properties"]["watering"]["default"], + "weekly" + ); + } + + /// Port of TS: "should handle root-level targeting" + #[test] + fn test_fern_root_level_targeting() { + let doc = json!({ + "openapi": "3.0.0", + "info": { "title": "Plant API", "version": "1.0.0" }, + "paths": { "/plants": { "get": { "summary": "Get plants" } } }, + "tags": [{ "name": "legacy", "description": "Legacy endpoints" }], + "components": { "securitySchemes": { + "apiKey": { "type": "apiKey", "in": "header", "name": "X-API-Key" } + }} + }); + let overlay = make_overlay(vec![ + update_action( + "$", + json!({ + "openapi": "3.0.0", + "info": { + "title": "Plant API", + "version": "1.0.0", + "description": "API for managing plants and gardens", + "contact": { "name": "Garden Team", "email": "garden@example.com" } + }, + "servers": [ + { "url": "https://api.example.com/v1", "description": "Production" }, + { "url": "https://staging.example.com/v1", "description": "Staging" } + ], + "externalDocs": { + "description": "Plant care guide", + "url": "https://docs.example.com" + } + }), + ), + remove_action("$.tags"), + remove_action("$.components"), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + // Added fields + assert_eq!(result["info"]["description"], "API for managing plants and gardens"); + assert!(result["servers"].is_array()); + assert_eq!(result["servers"].as_array().unwrap().len(), 2); + assert!(result["externalDocs"].is_object()); + // Removed fields + assert!(result.get("tags").is_none()); + assert!(result.get("components").is_none()); + // Preserved fields + assert_eq!(result["paths"]["/plants"]["get"]["summary"], "Get plants"); + } + + /// Port of TS: "should handle array edge cases including empty arrays and + /// replacing complete arrays" + #[test] + fn test_fern_array_edge_cases_append_and_replace() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": [] + }, + "zones": { + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5"] + }, + "companions": { + "type": "array", + "items": { "type": "object" }, + "enum": [] + } + } + }}} + }); + let overlay = make_overlay(vec![ + // Replace whole tags object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.tags", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["tropical", "succulent"] + }), + ), + // Replace whole zones object (including enum) via deep merge + update_action( + "$.components.schemas.Plant.properties.zones", + json!({ + "type": "array", + "items": { "type": "string" }, + "enum": ["zone5", "zone6", "zone7"] + }), + ), + // Append object to empty companions array + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "basil", "benefit": "pest control" }), + ), + // Append another object + update_action( + "$.components.schemas.Plant.properties.companions.enum", + json!({ "name": "marigold", "benefit": "pollination" }), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let props = &result["components"]["schemas"]["Plant"]["properties"]; + assert_eq!(props["tags"]["enum"], json!(["tropical", "succulent"])); + assert_eq!(props["zones"]["enum"], json!(["zone5", "zone6", "zone7"])); + let companions = props["companions"]["enum"].as_array().unwrap(); + assert_eq!(companions.len(), 2); + assert_eq!(companions[0]["name"], "basil"); + assert_eq!(companions[1]["name"], "marigold"); + } + + /// Port of TS: "should not mutate the input data object" + #[test] + fn test_fern_does_not_mutate_input() { + let doc = json!({ + "components": { "schemas": { "Plant": { + "type": "object", + "properties": { "species": { "type": "string" } } + }}} + }); + let original = doc.clone(); + let overlay = make_overlay(vec![update_action( + "$.components.schemas.Plant", + json!({ + "type": "object", + "properties": { + "species": { "type": "string" }, + "color": { "type": "string" } + } + }), + )]); + let _result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!(doc, original); + } + + /// Port of TS: "should handle complex JSONPath expressions including + /// recursive descent and filters" — array index targeting + #[test] + fn test_fern_array_index_targeting() { + let doc = json!({ + "paths": { "/plants": { "get": { "parameters": [ + { "name": "limit", "in": "query", "schema": { "type": "integer" } }, + { "name": "offset", "in": "query", "schema": { "type": "integer" } } + ]}}} + }); + let overlay = make_overlay(vec![update_action( + "$.paths['/plants'].get.parameters[0]", + json!({ + "name": "limit", "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 100 }, + "description": "Maximum number of items to return" + }), + )]); + let result = apply_overlay(&doc, &overlay).unwrap(); + let params = &result["paths"]["/plants"]["get"]["parameters"]; + assert_eq!(params[0]["description"], "Maximum number of items to return"); + assert_eq!(params[0]["schema"]["minimum"], 1); + // Second param untouched + assert!(params[1].get("description").is_none()); + } + + // -- Additional deep_merge tests for lodash parity -- + + /// Verify lodash-style index-by-index array merge + #[test] + fn test_deep_merge_arrays_index_by_index() { + let mut base = json!([1, 2, 3]); + let update = json!([10, 20]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 3])); + } + + /// Verify array merge with objects inside arrays + #[test] + fn test_deep_merge_arrays_of_objects() { + let mut base = json!([ + { "name": "a", "value": 1 }, + { "name": "b", "value": 2 } + ]); + let update = json!([ + { "name": "a", "value": 10, "extra": true } + ]); + deep_merge(&mut base, &update); + assert_eq!(base[0]["value"], 10); + assert_eq!(base[0]["extra"], true); + assert_eq!(base[1]["value"], 2); // second element preserved + } + + /// Verify array append appends objects to array target + #[test] + fn test_merge_at_path_array_append() { + let mut doc = json!({ "items": [] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({ "id": 1 })); + merge_at_path(&mut doc, &segments, &json!({ "id": 2 })); + assert_eq!(doc["items"], json!([{ "id": 1 }, { "id": 2 }])); + } + + /// Verify that update with longer array extends the base + #[test] + fn test_deep_merge_update_extends_shorter_array() { + let mut base = json!([1]); + let update = json!([10, 20, 30]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 1 verification: array append scope — widened guard pushes any + // non-array value (objects, strings, numbers, booleans, null) matching + // the Fern CLI TS behavior. + // ----------------------------------------------------------------------- + + #[test] + fn test_array_append_object() { + let mut doc = json!({ "items": [{"id": 1}] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!({"id": 2})); + assert_eq!(doc["items"], json!([{"id": 1}, {"id": 2}])); + } + + #[test] + fn test_array_append_string() { + let mut doc = json!({ "tags": ["a", "b"] }); + let segments = vec![PathSegment::Key("tags".into())]; + merge_at_path(&mut doc, &segments, &json!("c")); + assert_eq!(doc["tags"], json!(["a", "b", "c"])); + } + + #[test] + fn test_array_append_number() { + let mut doc = json!({ "nums": [1, 2] }); + let segments = vec![PathSegment::Key("nums".into())]; + merge_at_path(&mut doc, &segments, &json!(3)); + assert_eq!(doc["nums"], json!([1, 2, 3])); + } + + #[test] + fn test_array_append_boolean() { + let mut doc = json!({ "flags": [true] }); + let segments = vec![PathSegment::Key("flags".into())]; + merge_at_path(&mut doc, &segments, &json!(false)); + assert_eq!(doc["flags"], json!([true, false])); + } + + #[test] + fn test_array_append_null() { + let mut doc = json!({ "items": [1] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &Value::Null); + assert_eq!(doc["items"], json!([1, null])); + } + + #[test] + fn test_array_replace_with_array() { + let mut doc = json!({ "items": [1, 2] }); + let segments = vec![PathSegment::Key("items".into())]; + merge_at_path(&mut doc, &segments, &json!([10, 20, 30])); + // Arrays merge index-by-index via deep_merge + assert_eq!(doc["items"], json!([10, 20, 30])); + } + + // ----------------------------------------------------------------------- + // Item 2 verification: lodash merge vs deep_merge edge cases + // ----------------------------------------------------------------------- + + #[test] + fn test_deep_merge_arrays_of_arrays() { + let mut base = json!([[1, 2], [3, 4]]); + let update = json!([[10], [30, 40, 50]]); + deep_merge(&mut base, &update); + // Index-by-index: base[0] merges with [10], base[1] with [30,40,50] + assert_eq!(base, json!([[10, 2], [30, 40, 50]])); + } + + #[test] + fn test_deep_merge_mixed_type_arrays() { + let mut base = json!([1, "hello", {"a": 1}, [1, 2]]); + let update = json!([99, "world", {"b": 2}, [3]]); + deep_merge(&mut base, &update); + // Primitives replaced, objects merged, arrays merged index-by-index + assert_eq!(base, json!([99, "world", {"a": 1, "b": 2}, [3, 2]])); + } + + #[test] + fn test_deep_merge_sparse_like_arrays() { + // lodash.merge with sparse arrays fills gaps — our impl uses + // index-by-index so shorter base just gets extended + let mut base = json!([1]); + let update = json!([null, null, 3]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([null, null, 3])); + } + + #[test] + fn test_deep_merge_empty_arrays() { + let mut base = json!([1, 2, 3]); + let update = json!([]); + deep_merge(&mut base, &update); + // Empty update leaves base unchanged + assert_eq!(base, json!([1, 2, 3])); + } + + #[test] + fn test_deep_merge_nested_objects_in_arrays() { + let mut base = json!([{"a": {"x": 1}}, {"b": 2}]); + let update = json!([{"a": {"y": 2}}, {"c": 3}]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([{"a": {"x": 1, "y": 2}}, {"b": 2, "c": 3}])); + } + + #[test] + fn test_deep_merge_array_type_mismatch_replaces() { + // When base is object and update is array (or vice versa), replace + let mut base = json!({"a": 1}); + let update = json!([1, 2]); + deep_merge(&mut base, &update); + assert_eq!(base, json!([1, 2])); + + let mut base = json!([1, 2]); + let update = json!({"a": 1}); + deep_merge(&mut base, &update); + assert_eq!(base, json!({"a": 1})); + } + + // ----------------------------------------------------------------------- + // Item 3 verification: YAML ↔ JSON roundtrip fidelity + // ----------------------------------------------------------------------- + + #[test] + fn test_yaml_roundtrip_strips_comments() { + let yaml_with_comments = r#" +openapi: "3.0.0" +info: + title: Test # inline comment + version: "1.0" +# full line comment +paths: {} +"#; + // Need a no-op overlay to trigger the YAML->JSON->YAML roundtrip + // (empty overlay list short-circuits and returns original string) + let noop_overlay = r#" +overlay: "1.0.0" +info: + title: noop + version: "1.0.0" +actions: + - target: "$.__nonexistent__" + update: + x: 1 +"#; + let result = apply_overlays_to_spec( + yaml_with_comments, + &[noop_overlay.to_string()], + ) + .unwrap(); + // Comments are stripped after roundtrip + assert!(!result.contains("# inline comment"), "inline comment should be stripped: {result}"); + assert!(!result.contains("# full line comment"), "line comment should be stripped: {result}"); + assert!(result.contains("title: Test")); + } + + #[test] + fn test_yaml_roundtrip_resolves_anchors() { + // serde_yaml resolves anchors/aliases during deserialization. + // Use a simple alias (not merge key) to verify resolution. + let yaml_with_anchors = r#" +base_url: &url "https://api.example.com" +servers: + - url: *url + description: production +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_anchors).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Alias is resolved to the concrete value + assert_eq!( + json_val["servers"][0]["url"], + "https://api.example.com" + ); + assert_eq!( + json_val["servers"][0]["description"], + "production" + ); + // The anchor definition is also present as a regular key + assert_eq!( + json_val["base_url"], + "https://api.example.com" + ); + } + + #[test] + fn test_yaml_roundtrip_strips_custom_tags() { + let yaml_with_tag = r#" +value: !custom_tag + inner: data +"#; + let yaml_value: serde_yaml::Value = + serde_yaml::from_str(yaml_with_tag).unwrap(); + let json_val = yaml_to_json(yaml_value); + // Custom tags are stripped, value preserved + assert_eq!(json_val["value"]["inner"], "data"); + } + + #[test] + fn test_yaml_roundtrip_with_overlay_preserves_structure() { + let spec = r#" +openapi: "3.0.0" +info: + title: Test API # comment will be stripped + version: "1.0" +paths: + /users: + get: + summary: List users +"#; + let overlay = r#" +overlay: "1.0.0" +info: + title: add-description + version: "1.0.0" +actions: + - target: "$.info" + update: + description: "Added by overlay" +"#; + let result = + apply_overlays_to_spec(spec, &[overlay.to_string()]).unwrap(); + assert!(result.contains("description: Added by overlay")); + assert!(result.contains("title: Test API")); + assert!(!result.contains('#')); + } + + // ----------------------------------------------------------------------- + // Item 4 verification: special characters in JSON keys via overlay paths + // ----------------------------------------------------------------------- + + #[test] + fn test_overlay_key_with_special_chars() { + let doc = json!({ + "x-extension": {"value": 1}, + "paths": { + "/users/{id}": { + "get": {"summary": "get user"} + } + } + }); + let overlay = make_overlay(vec![ + update_action( + "$.paths['/users/{id}'].get", + json!({"description": "Get a user by ID"}), + ), + update_action( + "$['x-extension']", + json!({"extra": true}), + ), + ]); + let result = apply_overlay(&doc, &overlay).unwrap(); + assert_eq!( + result["paths"]["/users/{id}"]["get"]["description"], + "Get a user by ID" + ); + assert_eq!(result["x-extension"]["extra"], true); + assert_eq!(result["x-extension"]["value"], 1); + } + + #[test] + fn test_normalized_path_to_segments_direct() { + // Verify the iterator-based approach works for keys with special chars + let doc = json!({ + "it's": {"nested": true}, + "key[0]": "bracket-key" + }); + let path = serde_json_path::JsonPath::parse("$[\"it's\"]").unwrap(); + let located = path.query_located(&doc); + for node in located.iter() { + let segments = normalized_path_to_segments(node.location()); + assert_eq!(segments, vec![PathSegment::Key("it's".into())]); + } + } + + +} diff --git a/src/openapi/parser.rs b/src/openapi/parser.rs new file mode 100644 index 0000000..361d8b9 --- /dev/null +++ b/src/openapi/parser.rs @@ -0,0 +1,11469 @@ +//! OpenAPI 3.0 Parser +//! +//! Converts an OpenAPI 3.0 YAML specification into the internal `RestDescription` +//! representation used by the CLI command builder and executor. + +use std::collections::HashMap; + +use serde::{Deserialize, Deserializer}; + +use crate::text::to_kebab_flag; +use crate::openapi::discovery::{ + Availability, BinaryRequestBody, BodyEncoding, GlobalHeader, GlobalParameter, + GlobalParameterApplyMode, GlobalParameterLocation, IdempotencyHeader, JsonSchema, + JsonSchemaProperty, MethodParameter, MultipartField, PaginationConfig, RestDescription, + RestMethod, RestResource, RetriesConfig, SchemaRef, SdkGroupInfo, SdkVariable, + SecurityScheme, StreamingConfig, +}; +use crate::error::CliError; + +/// Deserialize `x-fern-sdk-group-name` as either a string scalar or a list of +/// strings. The Fern extension allows both forms; specs like AssemblyAI's use +/// the scalar form while internal fixtures use the list form for nesting. +fn deserialize_group_name<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrList { + String(String), + List(Vec), + } + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(StringOrList::String(s)) => Ok(Some(vec![s])), + Some(StringOrList::List(v)) => Ok(Some(v)), + } +} + +/// Deserialize `x-fern-global-parameter` as either a single string or an +/// array of strings. The extension accepts both forms for convenience. +fn deserialize_global_parameter_opt_ins<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrList { + String(String), + List(Vec), + } + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(StringOrList::String(s)) => Ok(Some(vec![s])), + Some(StringOrList::List(v)) => Ok(Some(v)), + } +} + +// --------------------------------------------------------------------------- +// YAML deep-merge (Fern overrides support) +// --------------------------------------------------------------------------- + +/// Recursively deep-merge `overrides` onto `base`, matching the Fern CLI's +/// `mergeWithOverrides` behavior (lodash `mergeWith` + `omitDeepBy(isNull)`). +/// +/// Maps merge key-by-key (override wins on leaf collisions). Arrays of objects +/// merge element-by-element by index; if the override array is shorter the base +/// tail is kept, if longer the override tail is appended. Arrays of primitives +/// (or mixed) replace wholesale. Scalars replace. Null values in overrides +/// delete the key from the base; null removal is applied recursively. +/// Keys whose descendants preserve `null` values during the post-merge +/// null-removal pass. Matches the Fern CLI's `OPENAPI_EXAMPLES_KEYS` constant +/// used as `allowNullKeys` in `loadOpenAPI.ts`. +const ALLOW_NULL_KEYS: &[&str] = &[ + "examples", + "example", + "x-fern-examples", + "x-code-samples", + "x-codeSamples", +]; + +pub fn deep_merge_yaml( + base: serde_yaml::Value, + overrides: serde_yaml::Value, +) -> serde_yaml::Value { + let merged = deep_merge_yaml_inner(base, overrides); + remove_nulls(merged, false) +} + +/// Returns `true` if every element in the YAML sequence is a mapping (object). +fn all_objects(seq: &[serde_yaml::Value]) -> bool { + seq.iter().all(|v| v.is_mapping()) +} + +/// Core merge without null-removal (applied once at the top level). +fn deep_merge_yaml_inner( + base: serde_yaml::Value, + overrides: serde_yaml::Value, +) -> serde_yaml::Value { + match (base, overrides) { + (serde_yaml::Value::Mapping(mut base_map), serde_yaml::Value::Mapping(override_map)) => { + for (key, override_val) in override_map { + if let Some(base_val) = base_map.remove(&key) { + base_map.insert(key, deep_merge_yaml_inner(base_val, override_val)); + } else { + base_map.insert(key, override_val); + } + } + serde_yaml::Value::Mapping(base_map) + } + ( + serde_yaml::Value::Sequence(base_seq), + serde_yaml::Value::Sequence(override_seq), + ) => { + // Fern parity: arrays of objects are merged element-by-element + // (by index). Arrays of primitives (or mixed) replace wholesale. + if all_objects(&base_seq) && all_objects(&override_seq) { + let mut result: Vec = Vec::with_capacity( + std::cmp::max(base_seq.len(), override_seq.len()), + ); + let mut base_iter = base_seq.into_iter(); + let mut ovr_iter = override_seq.into_iter(); + loop { + match (base_iter.next(), ovr_iter.next()) { + (Some(b), Some(o)) => result.push(deep_merge_yaml_inner(b, o)), + (Some(b), None) => result.push(b), + (None, Some(o)) => result.push(o), + (None, None) => break, + } + } + serde_yaml::Value::Sequence(result) + } else { + serde_yaml::Value::Sequence(override_seq) + } + } + // All other types: override replaces the base. + (_base, override_val) => override_val, + } +} + +/// Recursively walk a YAML value and remove any key whose value is `null`. +/// This matches the Fern CLI's `omitDeepBy(isNull)` post-merge pass. +/// +/// When `allow_nulls` is `true` (i.e. we are inside a key listed in +/// `ALLOW_NULL_KEYS`, such as `"examples"`), null values are preserved +/// instead of being stripped. The flag propagates to all descendants. +fn remove_nulls(value: serde_yaml::Value, allow_nulls: bool) -> serde_yaml::Value { + match value { + serde_yaml::Value::Mapping(map) => { + let mut cleaned = serde_yaml::Mapping::new(); + for (k, v) in map { + let key_str = k.as_str().unwrap_or(""); + let child_allow = allow_nulls || ALLOW_NULL_KEYS.contains(&key_str); + if !child_allow && v.is_null() { + continue; + } + cleaned.insert(k, remove_nulls(v, child_allow)); + } + serde_yaml::Value::Mapping(cleaned) + } + serde_yaml::Value::Sequence(seq) => { + serde_yaml::Value::Sequence( + seq.into_iter().map(|v| remove_nulls(v, allow_nulls)).collect(), + ) + } + other => other, + } +} + +// --------------------------------------------------------------------------- +// Serde structs for OpenAPI 3.0 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct OpenApiSpec { + info: OpenApiInfo, + #[serde(default)] + servers: Vec, + #[serde(default)] + paths: HashMap, + /// OpenAPI 3.1 top-level `webhooks` block. Webhooks describe operations + /// the *server* sends to the user (inbound from the CLI's perspective), + /// so they are captured but intentionally not lowered into CLI + /// subcommands. Any component schemas they reference remain reachable + /// via `components.schemas` regardless. + #[serde(default)] + webhooks: HashMap, + components: Option, + /// Spec-level default security. Each entry is an alternative; within an + /// entry the keys are scheme names (their values are the requested + /// OAuth2/OpenIDConnect scopes — empty arrays for HTTP/apiKey schemes). + /// Inherited by every operation that doesn't declare its own `security`. + #[serde(default)] + security: Option>>>, + /// Spec-root `x-fern-pagination` extension. Inherited by operations that + /// set `x-fern-pagination: true` instead of their own config block. + #[serde(default, rename = "x-fern-pagination")] + x_fern_pagination: Option, + /// Spec-root `x-fern-base-path` extension. Declares a common prefix + /// prepended to every operation path at request time. See + /// [`RestDescription::base_path`] for the runtime behavior. + #[serde(default, rename = "x-fern-base-path")] + x_fern_base_path: Option, + /// Spec-root [`x-fern-idempotency-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotency-headers) + /// extension. List of headers that idempotent operations accept. + #[serde(default, rename = "x-fern-idempotency-headers")] + x_fern_idempotency_headers: Option>, + /// Spec-root `x-fern-sdk-variables` extension. Lowered into + /// `RestDescription::sdk_variables` via `parse_sdk_variables`. + #[serde(default, rename = "x-fern-sdk-variables")] + x_fern_sdk_variables: Option, + /// Spec-root [`x-fern-retries`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/retries) + /// extension. May be a boolean shorthand (`true` enables defaults, + /// `false` disables) or an object describing the retry policy. + /// Inherited by every operation that omits its own block or sets it + /// to `true`. Mirrors upstream fern's `getFernRetriesExtension`, + /// extended with the optional `max_attempts` / `base_delay_ms` / + /// `factor` / `jitter` knobs the runtime retry loop consumes. + #[serde(default, rename = "x-fern-retries")] + x_fern_retries: Option, + /// Spec-root [`x-fern-global-headers`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/global-headers) + /// extension. List of headers stamped on every outgoing request. + #[serde(default, rename = "x-fern-global-headers")] + x_fern_global_headers: Option>, + /// Spec-root `x-fern-global-parameters` extension. Generalizes + /// `x-fern-global-headers` to support header, query, body, and path + /// locations with apply-mode control (`auto` vs `explicit`). + #[serde(default, rename = "x-fern-global-parameters")] + x_fern_global_parameters: Option>, + /// Spec-root [`x-fern-groups`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/groups) + /// extension. Mirrors the upstream Fern OpenAPI importer's + /// `getFernGroups.ts`: a record mapping group identifiers to + /// `{ summary?, description? }` metadata. Lowered into + /// [`RestDescription::groups`] (keyed by the kebab-cased identifier + /// so it matches the resource keys built from + /// `x-fern-sdk-group-name`). + #[serde(default, rename = "x-fern-groups")] + x_fern_groups: Option>, +} + +/// Raw deserialized form of a single entry in `x-fern-idempotency-headers`. +/// Mirrors the upstream Fern OpenAPI importer's `IdempotencyHeaderExtension` +/// shape (`fern-api/fern` `getIdempotencyHeaders.ts`). +#[derive(Debug, Deserialize, Clone)] +struct RawIdempotencyHeader { + /// HTTP header name (e.g. `Idempotency-Key`). Required. + header: String, + /// Optional SDK/CLI parameter name override. + #[serde(default)] + name: Option, + /// Optional environment variable name supplying a default value. + #[serde(default)] + env: Option, +} + +/// Raw deserialized form of a single entry in `x-fern-global-headers`. +/// Mirrors the upstream Fern OpenAPI importer's `GlobalHeaderExtension` +/// shape (`fern-api/fern` `getGlobalHeaders.ts`): `header` is the only +/// required field; everything else tunes the SDK/CLI surface. +/// +/// Both `default` and `x-fern-default` are accepted for the baked-in +/// fallback value. When both are present, `x-fern-default` wins — +/// mirroring the broader Fern convention where the prefixed extension +/// is the explicit form. +#[derive(Debug, Deserialize, Clone)] +struct RawGlobalHeader { + /// HTTP header name (e.g. `X-API-Version`). Required. + header: String, + /// Optional SDK/CLI parameter name override. Drives the kebab-cased + /// flag name when present (`apiVersion` → `--api-version`). + #[serde(default)] + name: Option, + /// When `true`, the header is omitted from outgoing requests when + /// no value resolves. Defaults to `false` (required). + #[serde(default)] + optional: Option, + /// Optional environment variable name supplying a fallback value. + #[serde(default)] + env: Option, + /// Optional baked-in default value. Surfaced in `--help` and sent + /// on the wire when neither the flag nor the env var is supplied. + #[serde(default)] + default: Option, + /// Alternate baked-in default. Wins over `default` when both are + /// present (mirrors `x-fern-default` precedence elsewhere in the + /// Fern OpenAPI importer). + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, +} + +/// Raw deserialized form of a single entry in `x-fern-global-parameters`. +/// Generalizes [`RawGlobalHeader`] to support header, query, body, and +/// path locations with apply-mode control. +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +struct RawGlobalParameter { + /// Parameter name (e.g. `currency`, `x-custom-header`). Required. + name: String, + /// Where the value is injected: `header`, `query`, `body`, or `path`. + /// Defaults to `header` when absent. + #[serde(default, rename = "in")] + location: Option, + /// Wire-level target. For headers: the header name; for query: the + /// query param name; for body: a dotted JSON path; for path: the + /// path template variable. Defaults to `name` when absent. + #[serde(default)] + target: Option, + /// Optional environment variable name supplying a fallback value. + #[serde(default)] + env: Option, + /// Optional baked-in default value. + #[serde(default)] + default: Option, + /// Alternate baked-in default. Wins over `default` when both present. + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + /// When `true`, the parameter is omitted when no value resolves. + /// Defaults to `false` (required). + #[serde(default)] + optional: Option, + /// `auto` (default) or `explicit`. Controls whether the parameter + /// is injected on all operations or only opted-in ones. + #[serde(default)] + apply: Option, + /// Optional flag name override (e.g. `maxRetries` → `--max-retries`). + #[serde(default)] + parameter_name: Option, + /// One-line help text for `--help`. + #[serde(default)] + docs: Option, +} + +/// Raw deserialized form of a single entry in the document-root +/// `x-fern-groups` map. Mirrors the upstream Fern OpenAPI importer's +/// `XFernGroupsSchema` zod schema (`getFernGroups.ts` → +/// `{ summary?: string, description?: string }`). +/// +/// Both fields are optional; the matching IR shape exposed by fern +/// (`SdkGroupInfo` in `finalIr.yml`) preserves them verbatim and the +/// `display-name` token shown in the JSDoc comment of fern's extension +/// enum is *not* part of the enforced schema — `summary` is the +/// real field name on the wire. +#[derive(Debug, Deserialize, Clone, Default)] +struct RawFernGroup { + /// Short human-friendly label for the group. Surfaces as the + /// clap subcommand's `about()` line when set. + #[serde(default)] + summary: Option, + /// Longer prose description for the group. Surfaces as the + /// clap subcommand's `long_about()` when set. + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiInfo { + title: Option, + version: String, + description: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiServer { + url: String, + #[serde(default)] + description: Option, + /// Fern v2 spelling — the canonical extension name for naming a server. + /// When both v1 and v2 are present on the same entry, v1 wins to + /// mirror the upstream `fern-api/fern` OpenAPI importer, which + /// resolves the name via + /// `getExtension(server, [SERVER_NAME_V1, SERVER_NAME_V2])` — + /// `getExtension` returns the first matching key, so the v1 alias + /// `x-name` lands first. See + /// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts` + /// lines 72-75 and `.../src/getExtension.ts` lines 25-35. + #[serde(default, rename = "x-fern-server-name")] + x_fern_server_name: Option, + /// Fern v1 legacy alias. Recognized for backwards compatibility with + /// older specs that haven't migrated to `x-fern-server-name`. When + /// both extensions are present, this v1 spelling wins — see the + /// doc-comment on `x_fern_server_name` for the precedence citation. + #[serde(default, rename = "x-name")] + x_name: Option, +} + +impl OpenApiServer { + /// Resolve the server name, applying v1-over-v2 precedence to + /// match fern's `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` + /// first-match-wins behavior in + /// `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. + /// Each extension is trimmed and treated as "absent" when it is + /// the empty string (or whitespace-only) before the fallback runs, + /// so a blank `x-name: ""` does not shadow a valid + /// `x-fern-server-name` (and vice versa). An empty extension would + /// otherwise leak into clap as a blank-string possible value and a + /// blank `--help` row, which is always a spec bug — drop it at the + /// source so downstream code never needs to handle it. + fn resolved_name(&self) -> Option { + fn trimmed_non_empty(s: &Option) -> Option { + s.as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } + trimmed_non_empty(&self.x_name).or_else(|| trimmed_non_empty(&self.x_fern_server_name)) + } + + /// Lower the OpenAPI server entry into the internal + /// [`discovery::Server`] representation, applying the v1/v2 name + /// fallback (v1 wins; see [`Self::resolved_name`]). + fn to_discovery_server(&self) -> crate::openapi::discovery::Server { + crate::openapi::discovery::Server { + url: self.url.clone(), + name: self.resolved_name(), + description: self.description.clone(), + } + } +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiPathItem { + get: Option, + post: Option, + put: Option, + patch: Option, + delete: Option, + #[serde(default)] + parameters: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiOperation { + #[serde(rename = "operationId")] + operation_id: Option, + summary: Option, + description: Option, + #[serde(default)] + parameters: Vec, + #[serde(rename = "requestBody")] + request_body: Option, + /// `responses` map, keyed by status code (`"200"`, `"201"`, …) or + /// `"default"`. Values may be inline response objects or `$ref`s to + /// `components/responses/`. Consumed by [`extract_response`] + /// (after resolving refs) to select the primary success response. + #[serde(default)] + responses: HashMap, + #[serde(default)] + servers: Vec, + #[serde(default)] + tags: Option>, + #[serde(rename = "x-fern-sdk-group-name", default, deserialize_with = "deserialize_group_name")] + x_fern_sdk_group_name: Option>, + #[serde(rename = "x-fern-sdk-method-name")] + x_fern_sdk_method_name: Option, + /// Operation-level security override. `Some(vec![])` is meaningful — it + /// explicitly opts the operation out of the spec-level default, marking + /// it anonymous. `None` means "inherit the spec default". + #[serde(default)] + security: Option>>>, + /// Operation-level `x-fern-pagination`. May be: + /// - an object describing cursor / offset / uri / path / custom pagination (overrides root) + /// - the literal `true` (inherits the spec-root config) + /// - missing (falls back to the document-wide pagination heuristic) + #[serde(default, rename = "x-fern-pagination")] + x_fern_pagination: Option, + /// Fern extension: when `Some(true)`, the operation is dropped from + /// the generated CLI surface — it does not appear as a subcommand, in + /// `--help`, or in completions. `None` (the default) and `Some(false)` + /// both keep the operation. Stored as `Option` to mirror the + /// nullish-coalescing precedence used at the parameter level. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/ignore + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// OpenAPI standard `deprecated: true` flag on the operation. When + /// `x-fern-availability` is absent, a `true` here is lowered to + /// `Availability::Deprecated` so deprecated operations still surface + /// a `[DEPRECATED]` badge in help output. + #[serde(default)] + deprecated: bool, + /// Raw `x-fern-availability` extension on the operation. When present, + /// takes precedence over the standard `deprecated` flag. + #[serde(rename = "x-fern-availability", default)] + x_fern_availability: Option, + /// [`x-fern-idempotent: true`](https://buildwithfern.com/learn/api-definitions/openapi/extensions/idempotent) + /// marker. When `true`, the operation surfaces spec-root idempotency + /// headers as CLI flags; non-idempotent operations never send these + /// headers. + #[serde(rename = "x-fern-idempotent", default)] + x_fern_idempotent: Option, + /// `x-fern-cli-idempotency: false` opt-out for auto Idempotency-Key. + /// When explicitly `false`, the executor does NOT inject the + /// auto-generated `Idempotency-Key` header on POST/PUT/PATCH. + #[serde(rename = "x-fern-cli-idempotency", default)] + x_fern_cli_idempotency: Option, + /// Raw `x-fern-sdk-return-value` extension on the operation. Mirrors + /// fern-api/fern's `FernOpenAPIExtension.RESPONSE_PROPERTY` — a + /// dot-separated key path into the JSON response body identifying + /// the subvalue to surface to the caller. `None` (the default) + /// means the executor prints the full response. + #[serde(rename = "x-fern-sdk-return-value", default)] + x_fern_sdk_return_value: Option, + /// Raw operation-level `x-fern-streaming` extension. May be: + /// - the literal `true` (boolean shorthand → NDJSON, no terminator) + /// - the literal `false` (explicit opt-out) + /// - an object describing the stream (`format`, optional `terminator`, + /// and the `stream-condition` / `response-stream` / `response` keys + /// recognized upstream for parity — only `format` and `terminator` + /// affect runtime behavior) + /// - missing (no streaming) + /// + /// Resolved into [`StreamingConfig`] via `parse_streaming_extension`. + #[serde(rename = "x-fern-streaming", default)] + x_fern_streaming: Option, + /// Operation-level `x-fern-retries`. Same shape as the spec-root + /// block (boolean shorthand or object). A boolean defers to the + /// spec-root block; an object merges field-by-field over the + /// spec-root baseline. Missing inherits the spec root verbatim. + #[serde(default, rename = "x-fern-retries")] + x_fern_retries: Option, + /// Raw `x-fern-audiences` extension on the operation. Mirrors + /// fern-api/fern's OpenAPI importer + /// (`FernOpenAPIExtension.AUDIENCES = "x-fern-audiences"`): an + /// array of strings declaring which audiences the operation is + /// part of. Missing or empty means "no audience tag" — and is + /// filtered OUT when the binary's `main.rs` configures any preset + /// audience via [`crate::openapi::CliApp::audiences`], matching + /// fern's `audiences.some(a => operationAudiences.includes(a))` + /// check in `generateIr.ts:141` (which always evaluates false when + /// `operationAudiences` is `[]`). + #[serde(rename = "x-fern-audiences", default)] + x_fern_audiences: Option>, + /// Per-operation `x-fern-global-parameter` opt-in. May be a single + /// string or an array of strings referencing global parameter names + /// declared in the spec-root `x-fern-global-parameters`. Only + /// `apply: explicit` parameters are affected — `apply: auto` + /// parameters ignore this field. + #[serde(rename = "x-fern-global-parameter", default, deserialize_with = "deserialize_global_parameter_opt_ins")] + x_fern_global_parameter: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum OpenApiParamOrRef { + /// A `$ref` to `components/parameters/`. The extension may also + /// be set on the ref-site object itself (Fern's overlay system and + /// OpenAPI 3.1 both allow extensions next to `$ref`); when present at + /// the ref site it wins over the resolved component's value. + Ref { + #[serde(rename = "$ref")] + ref_path: String, + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// Fern extension: an alias used as the CLI flag name while the + /// wire name (the resolved component's `name`) is still used in + /// the outgoing request. Set on the ref-site object — wins over + /// the value on the resolved component via fern's `??` + /// precedence (mirrors the `IGNORE` extension above). + #[serde(rename = "x-fern-parameter-name", default)] + x_fern_parameter_name: Option, + /// Ref-site `x-fern-default` value. Wins over the value on the + /// resolved component parameter (and over the standard + /// schema-level `default:`). Mirrors fern's importer precedence: + /// `getExtension(parameter, FERN_DEFAULT) ?? getExtension(resolvedParameter, FERN_DEFAULT)`. + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + }, + Inline(Box), +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiParameter { + name: String, + #[serde(rename = "in")] + location: Option, + #[serde(default)] + required: bool, + description: Option, + schema: Option, + #[serde(default)] + style: Option, + #[serde(default)] + explode: Option, + /// Fern extension: when `Some(true)`, the parameter is dropped from + /// the generated CLI surface — no CLI flag, not sent in the request. + /// Stored as `Option` so we can mirror fern's precedence: a + /// ref-site `x-fern-ignore` wins over the value on the resolved + /// component parameter via `ref_site.or(resolved).unwrap_or(false)`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/ignore + #[serde(rename = "x-fern-ignore", default)] + x_fern_ignore: Option, + /// Fern extension: alias used as the CLI flag name while the wire + /// name (`name`) is kept on the outgoing HTTP request. Mirrors + /// fern's OpenAPI importer (`parameterNameOverride`) and supports + /// the same precedence as `x-fern-ignore`: a ref-site value wins + /// over the resolved component's via `ref_site.or(resolved)`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/parameter-name + #[serde(rename = "x-fern-parameter-name", default)] + x_fern_parameter_name: Option, + /// OpenAPI standard `deprecated: true` flag on the parameter. When + /// `x-fern-availability` is absent, a `true` here is lowered to + /// `Availability::Deprecated` so deprecated parameter flags surface + /// a `[DEPRECATED]` badge in their `--help` description. + #[serde(default)] + deprecated: bool, + /// Raw `x-fern-availability` extension on the parameter. Takes + /// precedence over the standard `deprecated` flag. + #[serde(rename = "x-fern-availability", default)] + x_fern_availability: Option, + /// Fern extension: client-side default value for the parameter. + /// When present, the parameter becomes optional in the generated CLI + /// and the value is sent in the outgoing request when the user omits + /// the flag. Supports string, number, and boolean literals. + /// Wins over the standard `default:` on the parameter's `schema`. + /// A value placed at the **ref-site** (alongside `$ref`) wins over + /// the value on this resolved parameter — see `OpenApiParamOrRef::Ref`. + /// See https://buildwithfern.com/learn/api-definitions/openapi/extensions/default + #[serde(rename = "x-fern-default", default)] + x_fern_default: Option, + /// Fern extension binding this path parameter to a spec-level + /// `x-fern-sdk-variables` entry. Honored only on `in: path` + /// parameters (mirroring Fern's openapi-ir-parser). + #[serde(rename = "x-fern-sdk-variable", default)] + x_fern_sdk_variable: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct OpenApiParamSchema { + #[serde(rename = "type", default, deserialize_with = "deserialize_type_field")] + schema_type: Option, + #[serde(rename = "enum", default, deserialize_with = "deserialize_enum_values")] + enum_values: Option>, + default: Option, + format: Option, + /// JSON Schema numeric bounds on the parameter's schema. Surfaced + /// in `--schema` output via `MethodParameter::minimum`/`maximum`. + #[serde(default)] + minimum: Option, + #[serde(default)] + maximum: Option, + /// Raw `x-fern-enum` map keyed by wire value, deserialized straight + /// off the YAML schema. Lowered to `discovery::FernEnumValue` in + /// `convert_fern_enum`. + #[serde(rename = "x-fern-enum", default)] + x_fern_enum: Option>, +} + +/// Raw `x-fern-enum` entry as it appears in the OpenAPI YAML. Kept +/// schema-faithful (the `casing` field is parsed-but-ignored) so the +/// shape matches the upstream Fern importer. +#[derive(Debug, Deserialize, Default)] +struct OpenApiFernEnumValue { + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, + /// Parsed but not lowered — the SDK codegen uses `casing` to derive + /// language-specific identifiers; cli-sdk uses the raw display name. + #[serde(default)] + #[allow(dead_code)] + casing: Option, +} + +#[derive(Debug, Deserialize)] +struct OpenApiRequestBody { + content: Option>, + #[serde(rename = "x-fern-parameter-name")] + x_fern_parameter_name: Option, +} + +/// A single response entry under `responses` on an operation, e.g. +/// `responses.200.content.application/json.schema`. Only the `content` +/// sub-map is consumed today — descriptions and headers aren't surfaced. +#[derive(Debug, Deserialize, Default)] +struct OpenApiResponse { + #[serde(default)] + content: Option>, +} + +/// A response value that can be either an inline response object or a +/// `$ref` to `components/responses/`. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum OpenApiResponseOrRef { + Ref { + #[serde(rename = "$ref")] + ref_path: String, + }, + Inline(OpenApiResponse), +} + +#[derive(Debug, Deserialize)] +struct OpenApiMediaType { + schema: Option, + /// OpenAPI `encoding` object — per-property serialization overrides. + /// Only `contentType` is consumed (for multipart/form-data part + /// headers); `style` / `explode` / `headers` / `allowReserved` are not + /// yet acted upon. + #[serde(default)] + encoding: HashMap, +} + +/// A single entry in the OpenAPI `encoding` object. +#[derive(Debug, Deserialize, Default)] +struct OpenApiEncoding { + #[serde(rename = "contentType")] + content_type: Option, +} + +/// Captures the OpenAPI `type` field across the 3.0 string form +/// (`type: string`) and the 3.1 array form (`type: ["string", "null"]`). +/// `null_in_array` records whether `"null"` was present so nullability +/// can be reconstructed at access time. +#[derive(Debug, Default, Clone)] +struct TypeField { + schema_type: Option, + null_in_array: bool, +} + +impl<'de> Deserialize<'de> for TypeField { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de; + + struct TypeFieldVisitor; + + impl<'de> de::Visitor<'de> for TypeFieldVisitor { + type Value = TypeField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(TypeField { schema_type: Some(v.to_string()), null_in_array: false }) + } + + fn visit_string(self, v: String) -> Result { + Ok(TypeField { schema_type: Some(v), null_in_array: false }) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut types: Vec = Vec::new(); + while let Some(t) = seq.next_element::()? { + types.push(t); + } + let null_in_array = types.iter().any(|t| t == "null"); + let schema_type = types.into_iter().find(|t| t != "null"); + Ok(TypeField { schema_type, null_in_array }) + } + + fn visit_none(self) -> Result { + Ok(TypeField::default()) + } + + fn visit_unit(self) -> Result { + Ok(TypeField::default()) + } + } + + deserializer.deserialize_any(TypeFieldVisitor) + } +} + +/// `exclusiveMinimum` / `exclusiveMaximum` switched semantics between +/// OpenAPI 3.0 (boolean: modifies the sibling `minimum`/`maximum`) and 3.1 +/// (numeric: the bound itself). This enum preserves the wire form so the +/// accessors above can resolve to a single numeric bound consistently. +#[derive(Debug, Clone, Copy)] +enum ExclusiveBound { + Flag(bool), + Value(f64), +} + +impl<'de> Deserialize<'de> for ExclusiveBound { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde::de; + + struct ExclusiveBoundVisitor; + + impl<'de> de::Visitor<'de> for ExclusiveBoundVisitor { + type Value = ExclusiveBound; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean (OpenAPI 3.0) or a number (OpenAPI 3.1)") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(ExclusiveBound::Flag(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(ExclusiveBound::Value(v as f64)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(ExclusiveBound::Value(v as f64)) + } + + fn visit_f64(self, v: f64) -> Result { + Ok(ExclusiveBound::Value(v)) + } + } + + deserializer.deserialize_any(ExclusiveBoundVisitor) + } +} + +#[derive(Debug, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +struct OpenApiSchemaObject { + #[serde(rename = "$ref")] + schema_ref: Option, + /// Captures the wire `type` field in both its 3.0 string form and 3.1 + /// array form. Use `schema_type()` / `is_nullable()` instead of reading + /// directly — those accessors fold in the explicit `nullable` field. + #[serde(rename = "type", default)] + type_field: TypeField, + /// OpenAPI 3.0 explicit `nullable: true`. Removed in 3.1 (which expresses + /// the same idea via `"null"` in a type array). Both forms are surfaced + /// uniformly through `is_nullable()`. + #[serde(default)] + nullable: bool, + description: Option, + #[serde(default, deserialize_with = "deserialize_schema_properties")] + properties: HashMap, + items: Option>, + #[serde(default)] + required: Vec, + #[serde(rename = "enum", default, deserialize_with = "deserialize_enum_values")] + enum_values: Option>, + /// OpenAPI 3.1 / JSON Schema 2020-12 `const`: a schema that matches a + /// single literal value. Lowered into a one-element `enum_values` by + /// `convert_schema_property` so existing enum-aware code paths handle + /// it without further changes. + #[serde(rename = "const", default)] + const_value: Option, + /// JSON Schema inclusive numeric lower bound. In OpenAPI 3.0 the + /// boolean `exclusiveMinimum: true` re-interprets this as an exclusive + /// bound; in 3.1 the two fields are independent. Use the + /// `inclusive_min` / `exclusive_min` accessors to resolve correctly. + #[serde(default)] + minimum: Option, + /// JSON Schema inclusive numeric upper bound. See `minimum` above for + /// 3.0 vs 3.1 interaction notes. + #[serde(default)] + maximum: Option, + /// `exclusiveMinimum` in either OpenAPI 3.0 boolean form or 3.1 + /// numeric form. Resolved via `exclusive_min()`. + #[serde(default)] + exclusive_minimum: Option, + /// `exclusiveMaximum` in either OpenAPI 3.0 boolean form or 3.1 + /// numeric form. Resolved via `exclusive_max()`. + #[serde(default)] + exclusive_maximum: Option, + /// OpenAPI 3.0 / 3.1 single `example` value. Captured for documentation + /// surfacing; not used by request execution. + #[serde(default)] + example: Option, + /// `examples` block, captured as raw YAML so that all three real-world + /// shapes load successfully: + /// - OpenAPI 3.1 array of values: `examples: [a, b]` + /// - OpenAPI 3.0 MediaType-style map: `examples: { name: { value: ... } }` + /// (technically out-of-spec at the schema level, but several + /// real-world specs — e.g. BigCommerce — embed this form) + /// - Single value + /// + /// Downstream code is free to interpret the value based on its shape. + #[serde(default)] + examples: Option, + /// JSON Schema composition: value must match exactly one branch. + /// Heavily used in 3.1 specs (where nullability via type arrays plus + /// composition replaces the 3.0 `nullable` flag for complex unions), + /// and also present in 3.0. + #[serde(default)] + one_of: Vec, + /// JSON Schema composition: value must match at least one branch. + #[serde(default)] + any_of: Vec, + /// JSON Schema composition: value must match every branch (typically + /// used for inheritance / mixin patterns). + #[serde(default)] + all_of: Vec, + format: Option, + #[serde(default)] + read_only: bool, + /// OpenAPI's standard `default:` keyword on a schema (documentation hint + /// for what the server uses when the field is omitted). Captured as raw + /// YAML so we preserve the wire type — numbers stay numbers, booleans + /// stay booleans — and lowered to `serde_json::Value` at IR conversion + /// so `--schema` can surface it without re-quoting. Empty / absent ⇒ None. + #[serde(default)] + default: Option, + #[serde( + default, + deserialize_with = "deserialize_additional_properties" + )] + additional_properties: Option>, +} + +impl OpenApiSchemaObject { + /// The OpenAPI `type` value with any `"null"` array entry stripped. + /// Returns `None` when no type was given or when the type array + /// contained only `"null"`. + fn schema_type(&self) -> Option<&str> { + self.type_field.schema_type.as_deref() + } + + /// True when the schema is nullable per OpenAPI 3.0 (`nullable: true`) + /// or OpenAPI 3.1 (`"null"` in the type array). + fn is_nullable(&self) -> bool { + self.nullable || self.type_field.null_in_array + } + + /// Inclusive minimum, after applying the OpenAPI 3.0 rule that + /// `exclusiveMinimum: true` re-interprets `minimum` as exclusive. + fn inclusive_min(&self) -> Option { + match self.exclusive_minimum { + Some(ExclusiveBound::Flag(true)) => None, + _ => self.minimum, + } + } + + /// Inclusive maximum, with the same 3.0 re-interpretation rule applied. + fn inclusive_max(&self) -> Option { + match self.exclusive_maximum { + Some(ExclusiveBound::Flag(true)) => None, + _ => self.maximum, + } + } + + /// Exclusive lower bound resolved across both OpenAPI 3.0 + /// (boolean flag paired with `minimum`) and 3.1 (numeric form) wire + /// shapes. + fn exclusive_min(&self) -> Option { + match self.exclusive_minimum { + Some(ExclusiveBound::Value(n)) => Some(n), + Some(ExclusiveBound::Flag(true)) => self.minimum, + _ => None, + } + } + + /// Exclusive upper bound resolved across both wire shapes; see + /// `exclusive_min` for details. + fn exclusive_max(&self) -> Option { + match self.exclusive_maximum { + Some(ExclusiveBound::Value(n)) => Some(n), + Some(ExclusiveBound::Flag(true)) => self.maximum, + _ => None, + } + } +} + +/// Deserialize an OpenAPI `enum` field whose items may be strings, integers, or +/// booleans. Everything is coerced to `String`. +fn deserialize_enum_values<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct EnumVisitor; + + impl<'de> de::Visitor<'de> for EnumVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of scalar values") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut values = Vec::new(); + while let Some(v) = seq.next_element::()? { + values.push(yaml_scalar_to_string(&v)); + } + Ok(Some(values)) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(EnumVisitor) +} + +/// Deserialize an OpenAPI `type` field that can be a plain string or an array +/// (e.g. `["string", "null"]` in OpenAPI 3.1). When it's an array, the first +/// non-`"null"` entry is used. +fn deserialize_type_field<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct TypeVisitor; + + impl<'de> de::Visitor<'de> for TypeVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or array of strings") + } + + fn visit_str(self, v: &str) -> Result { + Ok(Some(v.to_string())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Some(v)) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut types: Vec = Vec::new(); + while let Some(t) = seq.next_element::()? { + types.push(t); + } + Ok(types.into_iter().find(|t| t != "null")) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(TypeVisitor) +} + +/// Deserialize `properties` tolerantly: each value is normally a schema object, +/// but some Fern-processed specs emit a single-element array wrapping the +/// schema (e.g. `[{"x-fern-type-name": "Foo"}]`). Single-element arrays +/// are unwrapped; other non-object values are replaced with an empty schema +/// so parsing continues instead of aborting. +fn deserialize_schema_properties<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let raw: HashMap = HashMap::deserialize(deserializer)?; + let mut result = HashMap::with_capacity(raw.len()); + for (key, value) in raw { + let schema_value = match &value { + serde_yaml::Value::Sequence(seq) if seq.len() == 1 => seq[0].clone(), + _ => value, + }; + let schema = serde_yaml::from_value::(schema_value) + .unwrap_or_default(); + result.insert(key, schema); + } + Ok(result) +} + +/// Deserialize `additionalProperties` which can be a boolean or a schema object. +/// When it's `false`, we treat it as None. When `true`, we treat it as an empty schema. +fn deserialize_additional_properties<'de, D>( + deserializer: D, +) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct AdditionalPropertiesVisitor; + + impl<'de> de::Visitor<'de> for AdditionalPropertiesVisitor { + type Value = Option>; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean or a schema object") + } + + fn visit_bool(self, v: bool) -> Result { + if v { + Ok(Some(Box::new(OpenApiSchemaObject::default()))) + } else { + Ok(None) + } + } + + fn visit_map>(self, map: M) -> Result { + let obj = OpenApiSchemaObject::deserialize(de::value::MapAccessDeserializer::new(map))?; + Ok(Some(Box::new(obj))) + } + + fn visit_none(self) -> Result { + Ok(None) + } + + fn visit_unit(self) -> Result { + Ok(None) + } + } + + deserializer.deserialize_any(AdditionalPropertiesVisitor) +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct OpenApiComponents { + #[serde(default, deserialize_with = "deserialize_schema_properties")] + schemas: HashMap, + #[serde(default)] + parameters: HashMap, + #[serde(default)] + responses: HashMap, + #[serde(default)] + security_schemes: HashMap, +} + +/// Raw OpenAPI Security Scheme Object — the shape we deserialize. Lowered +/// to [`crate::openapi::discovery::SecurityScheme`] before being surfaced. +#[derive(Debug, Deserialize, Default)] +struct OpenApiSecurityScheme { + #[serde(rename = "type")] + type_field: Option, + /// `bearer` or `basic` for `type: http`. + scheme: Option, + /// `header`, `query`, or `cookie` for `type: apiKey`. + #[serde(rename = "in")] + location: Option, + /// Header/query/cookie name for `type: apiKey`. + name: Option, +} + +fn lower_security_scheme(raw: &OpenApiSecurityScheme) -> SecurityScheme { + let type_str = raw.type_field.as_deref().unwrap_or("").to_ascii_lowercase(); + match type_str.as_str() { + "http" => match raw.scheme.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("bearer") => SecurityScheme::HttpBearer, + Some("basic") => SecurityScheme::HttpBasic, + other => SecurityScheme::Other(format!("http/{}", other.unwrap_or(""))), + }, + "apikey" => { + let name = raw.name.clone().unwrap_or_default(); + match raw.location.as_deref().map(str::to_ascii_lowercase).as_deref() { + Some("header") => SecurityScheme::ApiKeyHeader { name }, + Some("query") => SecurityScheme::ApiKeyQuery { name }, + other => SecurityScheme::Other(format!("apiKey/{}", other.unwrap_or(""))), + } + } + "oauth2" => SecurityScheme::OAuth2, + other => SecurityScheme::Other(other.to_string()), + } +} + +// --------------------------------------------------------------------------- +// Helper: camelCase → kebab-case +/// Detect pagination config from the OpenAPI spec's components/parameters. +/// Looks for common patterns like "page_token" or "PageToken" params, +/// and checks response schemas for pagination objects. +fn detect_pagination_config(spec: &OpenApiSpec) -> (Option, Option) { + let components = match &spec.components { + Some(c) => c, + None => return (None, None), + }; + + // Check if there's a page_token parameter in components + for param in components.parameters.values() { + if param.name == "page_token" { + // Calendly-style: page_token query param, pagination.next_page_token response + return ( + Some("page_token".to_string()), + Some("pagination.next_page_token".to_string()), + ); + } + } + + (None, None) +} + +// --------------------------------------------------------------------------- +// x-fern-pagination: resolve per-operation pagination config from the +// OpenAPI extension. Mirrors the upstream Fern OpenAPI importer: +// https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi-to-ir/src/extensions/x-fern-pagination.ts +// --------------------------------------------------------------------------- + +const REQUEST_PREFIX: &str = "$request."; +const RESPONSE_PREFIX: &str = "$response."; + +/// Strip a leading `$request.` or `$response.` prefix from a JSONPath-style +/// reference. The runtime treats the remaining string as either a request +/// parameter name (for `$request.foo` → `foo`) or a dotted JSON path into +/// the response body (for `$response.pagination.next_cursor` → +/// `pagination.next_cursor`). +fn strip_pagination_prefix(value: &str) -> String { + value + .strip_prefix(REQUEST_PREFIX) + .or_else(|| value.strip_prefix(RESPONSE_PREFIX)) + .unwrap_or(value) + .to_string() +} + +/// Normalize a spec-level `x-fern-base-path` value: +/// - `None` and empty/whitespace-only strings collapse to `None`. +/// - Otherwise the string is trimmed of surrounding ASCII whitespace and +/// returned as-is (leading/trailing slashes preserved — `build_url` +/// normalizes them at request time). +fn normalize_base_path(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Resolve the `x-fern-pagination` extension for a single operation, +/// applying root-level inheritance. +/// +/// Mirrors upstream `fern-api/fern`'s `getFernPaginationExtension`: +/// - per-op block absent → `Ok(None)` (executor falls back to heuristic) +/// - per-op block is a boolean → look up the spec-root block +/// - root is a boolean too → `Err(...)` (matches upstream's +/// `CliError::ValidationError`) +/// - root is absent → `Ok(None)` (NOT an error — matches upstream) +/// - root is an object → parse the root block +/// - per-op block is an object → parse it directly +fn resolve_pagination_extension( + op_ext: Option<&serde_yaml::Value>, + root_ext: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + let value = match op_ext { + Some(v) => v, + None => return Ok(None), + }; + + if let serde_yaml::Value::Bool(_) = value { + return match root_ext { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::Bool(_)) => Err(CliError::Discovery(format!( + "Operation '{op_id}' sets `x-fern-pagination: ` but the spec-root \ + `x-fern-pagination` is also a boolean; the root must be an object describing \ + pagination (cursor / offset / next_uri / next_path / custom)." + ))), + Some(root) => parse_pagination_config(root, op_id, true), + }; + } + + parse_pagination_config(value, op_id, false) +} + +/// Parse a `x-fern-pagination` config object. Discrimination order mirrors +/// `fern-api/fern`'s `getPaginationExtension.ts`: +/// +/// 1. `cursor` → Cursor form +/// 2. `next_uri` → Uri form +/// 3. `next_path` → Path form +/// 4. `offset` → Offset form +/// 5. `type: "custom"` → Custom form +/// +/// Otherwise an "invalid pagination extension" error is returned, matching +/// upstream's `CliError`. +/// +/// `inherited` is purely used for error wording so the user can tell +/// whether the failure is in the per-op block or the inherited root block. +fn parse_pagination_config( + value: &serde_yaml::Value, + op_id: &str, + inherited: bool, +) -> Result, CliError> { + let map = match value { + serde_yaml::Value::Mapping(m) => m, + _ => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-pagination` for operation '{op_id}': expected an object, \ + got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(value) + ))); + } + }; + + if map.contains_key("cursor") { + let cursor = require_str_field(map, "cursor", op_id)?; + let next_cursor = require_str_field(map, "next_cursor", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Cursor { + cursor: strip_pagination_prefix(&cursor), + next_cursor: strip_pagination_prefix(&next_cursor), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("next_uri") { + let next_uri = require_str_field(map, "next_uri", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Uri { + next_uri: strip_pagination_prefix(&next_uri), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("next_path") { + let next_path = require_str_field(map, "next_path", op_id)?; + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Path { + next_path: strip_pagination_prefix(&next_path), + results: strip_pagination_prefix(&results), + })); + } + + if map.contains_key("offset") { + let offset = require_str_field(map, "offset", op_id)?; + let results = require_str_field(map, "results", op_id)?; + let step = optional_str_field(map, "step", op_id)?; + let has_next_page = optional_str_field(map, "has-next-page", op_id)?; + return Ok(Some(PaginationConfig::Offset { + offset: strip_pagination_prefix(&offset), + results: strip_pagination_prefix(&results), + step: step.map(|s| strip_pagination_prefix(&s)), + has_next_page: has_next_page.map(|s| strip_pagination_prefix(&s)), + })); + } + + if matches!( + map.get(serde_yaml::Value::String("type".to_string())), + Some(serde_yaml::Value::String(t)) if t == "custom" + ) { + let results = require_str_field(map, "results", op_id)?; + return Ok(Some(PaginationConfig::Custom { + results: strip_pagination_prefix(&results), + })); + } + + Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': must declare one of `cursor`, \ + `next_uri`, `next_path`, `offset`, or `type: custom`. See \ + https://buildwithfern.com/learn/api-definitions/openapi/extensions/pagination" + ))) +} + +fn require_str_field( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, +) -> Result { + match map.get(serde_yaml::Value::String(field.to_string())) { + Some(serde_yaml::Value::String(s)) => Ok(s.clone()), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': field `{field}` must be \ + a string, got {}.", + describe_yaml_kind(other) + ))), + None => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': missing required field \ + `{field}`." + ))), + } +} + +fn optional_str_field( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, +) -> Result, CliError> { + match map.get(serde_yaml::Value::String(field.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `x-fern-pagination` for operation '{op_id}': field `{field}` must be \ + a string when present, got {}.", + describe_yaml_kind(other) + ))), + } +} + +// --------------------------------------------------------------------------- +// x-fern-streaming: resolve per-operation streaming config from the OpenAPI +// extension. Mirrors the upstream Fern OpenAPI importer: +// https://github.com/fern-api/fern/blob/main/packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernStreamingExtension.ts +// --------------------------------------------------------------------------- + +/// Resolve `x-fern-streaming` for a single operation. Returns: +/// - `Ok(None)` — extension absent, or set to literal `false` (explicit opt-out). +/// - `Ok(Some(_))` — streaming enabled; runtime variant captures format + terminator. +/// - `Err(...)` — invalid shape (non-bool/non-object, unknown `format`, etc.). +/// +/// Boolean shorthand (`x-fern-streaming: true`) maps to NDJSON +/// (`StreamingConfig::Json`) with no terminator. This matches the +/// upstream importer's boolean handler exactly — see the +/// `getFernStreamingExtension.ts` comment that the boolean shorthand +/// emits `format: "json"` (so that callers who haven't picked a wire +/// format don't accidentally inherit OpenAI-style SSE semantics). +fn parse_streaming_extension( + value: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + let value = match value { + Some(v) => v, + None => return Ok(None), + }; + + if let serde_yaml::Value::Bool(b) = value { + return if *b { + Ok(Some(StreamingConfig::Json { terminator: None })) + } else { + Ok(None) + }; + } + + let map = match value { + serde_yaml::Value::Mapping(m) => m, + other => { + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': expected a boolean or \ + an object, got {}.", + describe_yaml_kind(other) + ))); + } + }; + + // `format` is optional in upstream's object schema. The upstream + // importer and the typed SDKs (TS / C#) default a format-less + // object to `json` (NDJSON), matching the boolean shorthand. The + // CLI mirrors that default so callers who omit `format` get the + // same wire shape as the typed SDKs would have produced. + let format = optional_str_field_named(map, "format", op_id, "x-fern-streaming")?; + let format = match format.as_deref() { + Some("sse") => StreamingFormat::Sse, + Some("json") | None => StreamingFormat::Json, + Some("text") => StreamingFormat::Text, + Some(other) => { + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': field `format` must be \ + `sse`, `json`, or `text`, got `{other}`." + ))); + } + }; + + let terminator = + optional_str_field_named(map, "terminator", op_id, "x-fern-streaming")?; + + if matches!(format, StreamingFormat::Text) && terminator.is_some() { + // Mirrors the IR (`TextStreamChunk` carries no `terminator` + // field) and the typed SDK generators — surfacing it at parse + // time keeps misconfigurations from silently no-op'ing at + // runtime. + return Err(CliError::Discovery(format!( + "Invalid `x-fern-streaming` for operation '{op_id}': field `terminator` is not \ + supported for `format: text` streams." + ))); + } + + Ok(Some(match format { + StreamingFormat::Sse => StreamingConfig::Sse { terminator }, + StreamingFormat::Json => StreamingConfig::Json { terminator }, + StreamingFormat::Text => StreamingConfig::Text, + })) +} + +enum StreamingFormat { + Sse, + Json, + Text, +} + +fn optional_str_field_named( + map: &serde_yaml::Mapping, + field: &str, + op_id: &str, + extension: &str, +) -> Result, CliError> { + match map.get(serde_yaml::Value::String(field.to_string())) { + None | Some(serde_yaml::Value::Null) => Ok(None), + Some(serde_yaml::Value::String(s)) => Ok(Some(s.clone())), + Some(other) => Err(CliError::Discovery(format!( + "Invalid `{extension}` for operation '{op_id}': field `{field}` must be a string \ + when present, got {}.", + describe_yaml_kind(other) + ))), + } +} + +// --------------------------------------------------------------------------- +// x-fern-retries: resolve per-operation retry policy from the OpenAPI +// extension. Mirrors the upstream Fern OpenAPI importer's tagged shape +// (`getFernRetriesExtension.ts` — `{ disabled: bool }`) and extends it with +// the optional knobs the cli-sdk runtime retry loop consumes (max attempts, +// backoff base, factor, jitter). The extra knobs are forward-compatible with +// the upstream importer. +// --------------------------------------------------------------------------- + +/// Resolve the `x-fern-retries` extension for a single operation, applying +/// root-level inheritance and per-operation overrides. +/// +/// Precedence — matches the pagination resolver's shape and the upstream +/// fern importer's nullish coalescing: +/// - per-op block absent → inherit the spec-root block (or `None` when also absent) +/// - per-op `true` → spec-root config, or all-defaults when root is also absent +/// - per-op `false` (or `{ disabled: true }`) → disabled regardless of root +/// - per-op object → root values, overridden field-by-field by the op block; +/// when root is also `true`/absent the op object stacks on top of defaults +fn resolve_retries_extension( + op_ext: Option<&serde_yaml::Value>, + root_ext: Option<&serde_yaml::Value>, + op_id: &str, +) -> Result, CliError> { + // Build the baseline from the root block, if any. Root-`false` / + // `{ disabled: true }` propagates by default to operations that don't + // override it. + let root_baseline = match root_ext { + None | Some(serde_yaml::Value::Null) => None, + Some(v) => parse_retries_value(v, op_id, /*inherited=*/ true)?, + }; + + let op = match op_ext { + // Op missing → inherit the root baseline (or `None` when also absent). + Some(v) => v, + None => return Ok(root_baseline), + }; + + // Op is a boolean. + if let serde_yaml::Value::Bool(b) = op { + if !*b { + // `false` disables retries on this operation regardless of root. + return Ok(Some(RetriesConfig::disabled())); + } + // `true` adopts the root baseline; falls back to all-defaults when + // root is absent or also a boolean. + return Ok(Some(root_baseline.unwrap_or_default())); + } + + // Op is an object. The root baseline (if enabled) is the starting + // config; the op fields override field-by-field. When the root is + // explicitly disabled, the op block re-enables retries (the more + // specific block wins). + let baseline = match root_baseline { + Some(cfg) if cfg.enabled => cfg, + _ => RetriesConfig::default(), + }; + + let map = match op { + serde_yaml::Value::Mapping(m) => m, + other => { + return Err(CliError::Discovery(format!( + "Invalid operation-level `x-fern-retries` for operation '{op_id}': expected \ + an object or boolean, got {}.", + describe_yaml_kind(other) + ))); + } + }; + + let config = apply_retries_object(baseline, map, op_id, /*inherited=*/ false)?; + + // `max_attempts: 0` is treated identically to `disabled: true` so the + // executor doesn't have to special-case the count itself. + if config.max_attempts == 0 { + return Ok(Some(RetriesConfig::disabled())); + } + + Ok(Some(config)) +} + +/// Parse a standalone `x-fern-retries` value (root or operation) into a +/// [`RetriesConfig`]. Used for the root baseline: takes the raw extension +/// value and returns the resolved config (or `None` when the value is +/// `null`). Bool/object are handled inline; unknown shapes error out. +fn parse_retries_value( + value: &serde_yaml::Value, + op_id: &str, + inherited: bool, +) -> Result, CliError> { + match value { + serde_yaml::Value::Null => Ok(None), + serde_yaml::Value::Bool(true) => Ok(Some(RetriesConfig::default())), + serde_yaml::Value::Bool(false) => Ok(Some(RetriesConfig::disabled())), + serde_yaml::Value::Mapping(map) => { + let config = + apply_retries_object(RetriesConfig::default(), map, op_id, inherited)?; + if config.max_attempts == 0 { + return Ok(Some(RetriesConfig::disabled())); + } + Ok(Some(config)) + } + other => Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': expected an object or \ + boolean, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))), + } +} + +/// Apply the fields of an `x-fern-retries` object on top of an existing +/// [`RetriesConfig`]. Unknown keys are ignored (forward-compatible). +fn apply_retries_object( + mut config: RetriesConfig, + map: &serde_yaml::Mapping, + op_id: &str, + inherited: bool, +) -> Result { + // Canonical fern shape: `{ disabled: true | false }`. + if let Some(v) = map.get(serde_yaml::Value::String("disabled".to_string())) { + match v { + serde_yaml::Value::Bool(disabled) => { + if *disabled { + return Ok(RetriesConfig::disabled()); + } + config.enabled = true; + } + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `disabled` \ + must be a boolean, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + } + } + + // `max` / `max_attempts` / `max-attempts` — accept all three spellings + // since the upstream IR has not yet settled on one; the fern docs + // refer to "max retry attempts" colloquially. + if let Some(v) = retries_field(map, &["max_attempts", "max-attempts", "max"]) { + let parsed = match v { + serde_yaml::Value::Number(n) => n.as_u64(), + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must be a non-negative integer, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + }; + let parsed = parsed.ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must be a non-negative integer.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?; + config.max_attempts = u32::try_from(parsed).map_err(|_| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `max_attempts` \ + must fit in a u32, got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?; + } + + if let Some(v) = retries_field(map, &["base_delay_ms", "base-delay-ms", "base"]) { + let parsed = match v { + serde_yaml::Value::Number(n) => n.as_u64().ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field \ + `base_delay_ms` must be a non-negative integer.", + if inherited { "inherited" } else { "operation-level" }, + )) + })?, + other => { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field \ + `base_delay_ms` must be an integer, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))); + } + }; + config.base_delay_ms = parsed; + } + + if let Some(v) = retries_field(map, &["factor", "backoff_factor", "backoff-factor"]) { + let parsed = retries_required_f64(v, "factor", op_id, inherited)?; + if parsed < 1.0 { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `factor` must be \ + >= 1.0, got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + ))); + } + config.factor = parsed; + } + + if let Some(v) = retries_field(map, &["jitter"]) { + let parsed = retries_required_f64(v, "jitter", op_id, inherited)?; + if !(0.0..=1.0).contains(&parsed) { + return Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `jitter` must be \ + in [0.0, 1.0], got {parsed}.", + if inherited { "inherited" } else { "operation-level" }, + ))); + } + config.jitter = parsed; + } + + Ok(config) +} + +/// First-of-aliases lookup for `x-fern-retries` field reads. Returns the +/// first matching value (any present alias) so authors can use either +/// `max_attempts` / `max-attempts` / `max` (or the corresponding +/// `base_delay_ms` / `base-delay-ms` / `base`) interchangeably. +fn retries_field<'a>( + map: &'a serde_yaml::Mapping, + aliases: &[&str], +) -> Option<&'a serde_yaml::Value> { + for alias in aliases { + if let Some(v) = map.get(serde_yaml::Value::String((*alias).to_string())) { + return Some(v); + } + } + None +} + +fn retries_required_f64( + value: &serde_yaml::Value, + field: &str, + op_id: &str, + inherited: bool, +) -> Result { + match value { + serde_yaml::Value::Number(n) => n.as_f64().ok_or_else(|| { + CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `{field}` must be \ + a finite number.", + if inherited { "inherited" } else { "operation-level" }, + )) + }), + other => Err(CliError::Discovery(format!( + "Invalid {} `x-fern-retries` for operation '{op_id}': field `{field}` must be a \ + number, got {}.", + if inherited { "inherited" } else { "operation-level" }, + describe_yaml_kind(other) + ))), + } +} + +fn describe_yaml_kind(value: &serde_yaml::Value) -> &'static str { + match value { + serde_yaml::Value::Null => "null", + serde_yaml::Value::Bool(_) => "boolean", + serde_yaml::Value::Number(_) => "number", + serde_yaml::Value::String(_) => "string", + serde_yaml::Value::Sequence(_) => "array", + serde_yaml::Value::Mapping(_) => "object", + serde_yaml::Value::Tagged(_) => "tagged value", + } +} + +// --------------------------------------------------------------------------- + +fn camel_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for ch in s.chars() { + if !ch.is_ascii_alphanumeric() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + while result.ends_with('-') { + result.pop(); + } + result +} + +/// Tokenize a string the way Fern's OpenAPI importer does: camelCase-only +/// strings split on each capital letter; everything else splits on +/// non-alphanumeric runs. All tokens lowercased, empties dropped. +fn tokenize(s: &str) -> Vec { + let is_camel_case = s.chars().next().is_some_and(|c| c.is_ascii_lowercase()) + && s.chars().all(|c| c.is_ascii_alphanumeric()) + && s.chars().any(|c| c.is_ascii_uppercase()); + + let raw: Vec = if is_camel_case { + let mut tokens = Vec::new(); + let mut current = String::new(); + for c in s.chars() { + if c.is_ascii_uppercase() && !current.is_empty() { + tokens.push(std::mem::take(&mut current)); + } + current.push(c); + } + if !current.is_empty() { + tokens.push(current); + } + tokens + } else { + s.split(|c: char| !c.is_ascii_alphanumeric()) + .map(str::to_string) + .collect() + }; + + raw.into_iter() + .filter(|t| !t.is_empty()) + .map(|t| t.to_lowercase()) + .collect() +} + +/// Inject one synthetic header `MethodParameter` per spec-root +/// idempotency header into an idempotent operation's parameter map. The +/// existing header-parameter pathway in `commands.rs` and `executor.rs` +/// then handles flag exposure (kebab-cased `--`) and on-the-wire +/// header transmission (`location: "header"`). +/// +/// The parameter key (HashMap key) is the on-the-wire header name +/// (used directly as the HTTP header). The kebab-cased `--` +/// derives from [`IdempotencyHeader::name`] when present +/// (`MethodParameter.flag_name_override`), otherwise from the header. +/// This mirrors the upstream Fern OpenAPI importer, where `name` +/// becomes the SDK parameter identifier. +/// +/// Spec-declared parameters with the same HashMap key win — we do not +/// overwrite them, which preserves any per-operation customization +/// (e.g. an `Idempotency-Key` param declared explicitly in `parameters:` +/// with a custom description). +fn inject_idempotency_header_params( + params: &mut HashMap, + idempotency_headers: &[IdempotencyHeader], +) { + for h in idempotency_headers { + if params.contains_key(&h.header) { + continue; + } + let description = h + .name + .as_ref() + .map(|n| format!("Idempotency header `{}` (param `{}`).", h.header, n)) + .unwrap_or_else(|| format!("Idempotency header `{}`.", h.header)); + let flag_name_override = h.name.as_ref().map(|n| to_kebab_flag(n)); + params.insert( + h.header.clone(), + MethodParameter { + param_type: Some("string".to_string()), + description: Some(description), + location: Some("header".to_string()), + env_var: h.env.clone(), + flag_name_override, + ..Default::default() + }, + ); + } +} + +/// Mirror Fern's OpenAPI importer behavior: when an operation's group is +/// derived from a tag (no `x-fern-sdk-group-name`), strip tag tokens that +/// prefix the operationId. `tag="Customers", operationId="customersList"` +/// → `list`. No-op when the operationId doesn't start with the tag tokens. +fn strip_tag_prefix(operation_id: &str, tag: &str) -> String { + let tag_tokens = tokenize(tag); + let op_tokens = tokenize(operation_id); + if tag_tokens.is_empty() || op_tokens.len() <= tag_tokens.len() { + return operation_id.to_string(); + } + for (i, t) in tag_tokens.iter().enumerate() { + if op_tokens.get(i) != Some(t) { + return operation_id.to_string(); + } + } + op_tokens[tag_tokens.len()..].join("-") +} + +// --------------------------------------------------------------------------- +// Schema conversion helpers +// --------------------------------------------------------------------------- + +/// Resolve effective enum values for a schema, combining the OpenAPI `enum` +/// field with the OpenAPI 3.1 / JSON Schema 2020-12 `const` keyword. A +/// present `const` is lowered into a one-element enum so existing +/// enum-aware code paths (CLI flag value validation, help rendering) pick +/// it up without further changes. An explicit `enum` wins over `const` +/// when both are present. +fn effective_enum_values(obj: &OpenApiSchemaObject) -> Option> { + if let Some(values) = &obj.enum_values { + return Some(values.clone()); + } + let const_value = obj.const_value.as_ref()?; + Some(vec![yaml_scalar_to_string(const_value)]) +} + +/// Lower an `oneOf` / `anyOf` / `allOf` array of OpenAPI schemas into the +/// IR's `JsonSchemaProperty` form. Used by both `convert_schema_object` +/// (component-schema root) and `convert_schema_property` (nested property). +fn convert_composition_branches(branches: &[OpenApiSchemaObject]) -> Vec { + branches.iter().map(convert_schema_property).collect() +} + +/// If `obj` has an OpenAPI 3.1 / JSON Schema 2020-12 `const`, return the +/// const as a typed JSON value to install as the CLI flag's client-side +/// default. Pairs with the const→single-element enum lowering in +/// `effective_enum_values`: the flag accepts exactly the const value (or +/// rejects everything else via the enum parser), and becomes optional +/// because omitting it auto-injects the const at request time. +fn const_default_value(obj: &OpenApiSchemaObject) -> Option { + yaml_value_to_json(obj.const_value.as_ref()?) +} + +/// Coerce a YAML scalar (string, number, boolean) to its string form for +/// downstream use in CLI flag enumerations. Non-scalars fall back to the +/// Debug rendering — callers only invoke this on values that should be +/// scalar by spec, so the fallback is a diagnostic, not a feature. +fn yaml_scalar_to_string(v: &serde_yaml::Value) -> String { + match v { + serde_yaml::Value::String(s) => s.clone(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + other => format!("{other:?}"), + } +} + +fn convert_schema_object(obj: &OpenApiSchemaObject) -> JsonSchema { + if let Some(ref_path) = &obj.schema_ref { + let name = strip_ref_prefix(ref_path); + return JsonSchema { + schema_ref: Some(name), + ..Default::default() + }; + } + + let properties = obj + .properties + .iter() + .map(|(k, v)| (k.clone(), convert_schema_property(v))) + .collect(); + + JsonSchema { + id: None, + schema_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), + description: obj.description.clone(), + properties, + schema_ref: None, + items: obj.items.as_ref().map(|i| Box::new(convert_schema_property(i))), + required: obj.required.clone(), + one_of: convert_composition_branches(&obj.one_of), + any_of: convert_composition_branches(&obj.any_of), + all_of: convert_composition_branches(&obj.all_of), + additional_properties: obj + .additional_properties + .as_ref() + .map(|ap| Box::new(convert_schema_property(ap))), + } +} + +fn convert_schema_property(obj: &OpenApiSchemaObject) -> JsonSchemaProperty { + if let Some(ref_path) = &obj.schema_ref { + let name = strip_ref_prefix(ref_path); + return JsonSchemaProperty { + schema_ref: Some(name), + ..Default::default() + }; + } + + let properties = obj + .properties + .iter() + .map(|(k, v)| (k.clone(), convert_schema_property(v))) + .collect(); + + JsonSchemaProperty { + prop_type: obj.schema_type().map(str::to_string), + nullable: obj.is_nullable(), + description: obj.description.clone(), + schema_ref: None, + format: obj.format.clone(), + items: obj.items.as_ref().map(|i| Box::new(convert_schema_property(i))), + properties, + required: obj.required.clone(), + read_only: obj.read_only, + // Lower the YAML `default:` to a serde_json::Value so wire type + // (number / bool / object) survives. YAML constructs not + // representable in JSON (sequence-keyed maps) round-trip to + // `None` — that's acceptable for what is fundamentally a + // documentation hint. + default: obj + .default + .as_ref() + .and_then(|v| serde_json::to_value(v).ok()), + enum_values: effective_enum_values(obj), + minimum: obj.inclusive_min(), + maximum: obj.inclusive_max(), + exclusive_minimum: obj.exclusive_min(), + exclusive_maximum: obj.exclusive_max(), + example: obj.example.clone(), + examples: obj.examples.clone(), + one_of: convert_composition_branches(&obj.one_of), + any_of: convert_composition_branches(&obj.any_of), + all_of: convert_composition_branches(&obj.all_of), + additional_properties: obj + .additional_properties + .as_ref() + .map(|ap| Box::new(convert_schema_property(ap))), + } +} + +fn strip_ref_prefix(ref_path: &str) -> String { + // Handles "#/components/schemas/Foo" and "#/components/parameters/Foo" + ref_path + .rsplit('/') + .next() + .unwrap_or(ref_path) + .to_string() +} + +// --------------------------------------------------------------------------- +// x-fern-global-headers +// --------------------------------------------------------------------------- + +/// Lower a YAML scalar (string, integer, float, bool) used as a global +/// header's `default` into the on-the-wire string form. Returns `None` +/// for nulls, sequences, and mappings — those shapes aren't meaningful +/// as an HTTP header value, so we drop them rather than send something +/// nonsensical like `Some(["a","b"])` on the wire. +fn lower_global_header_default(value: &serde_yaml::Value) -> Option { + match value { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Bool(b) => Some(b.to_string()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + // Null, Sequence, Mapping, Tagged — not a valid header value. + _ => None, + } +} + +/// Lower the spec-root `x-fern-global-headers` block into the canonical +/// [`GlobalHeader`] discovery types. Mirrors the upstream Fern OpenAPI +/// importer's `getGlobalHeaders.ts`: entries without a `header` are +/// rejected at deserialize-time by serde; everything else is optional +/// and falls back to sensible defaults (required, no env, no default). +/// +/// `x-fern-default` wins over `default` when both are present. +fn lower_global_headers(raws: &[RawGlobalHeader]) -> Vec { + raws.iter() + .map(|raw| { + let default_yaml = raw.x_fern_default.as_ref().or(raw.default.as_ref()); + GlobalHeader { + header: raw.header.clone(), + name: raw.name.clone(), + optional: raw.optional.unwrap_or(false), + env: raw.env.clone(), + default: default_yaml.and_then(lower_global_header_default), + } + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-global-parameters +// --------------------------------------------------------------------------- + +/// Lower a YAML scalar used as a global parameter's `default` into a +/// string form. Reuses the same coercion as global headers — string, +/// bool, and number are representable; null / sequence / mapping are not +/// meaningful as a CLI flag default and are dropped. +fn lower_global_parameter_default(value: &serde_yaml::Value) -> Option { + lower_global_header_default(value) +} + +/// Lower the spec-root `x-fern-global-parameters` block into the canonical +/// [`GlobalParameter`] discovery types. `x-fern-default` wins over `default` +/// when both are present. +fn lower_global_parameters(raws: &[RawGlobalParameter]) -> Vec { + raws.iter() + .filter_map(|raw| { + let location = match raw.location.as_deref().unwrap_or("header") { + "header" => GlobalParameterLocation::Header, + "query" => GlobalParameterLocation::Query, + "body" => GlobalParameterLocation::Body, + "path" => GlobalParameterLocation::Path, + other => { + tracing::warn!( + name = %raw.name, + location = %other, + "x-fern-global-parameters entry has unsupported `in` value; skipping" + ); + return None; + } + }; + let apply = match raw.apply.as_deref().unwrap_or("auto") { + "auto" => GlobalParameterApplyMode::Auto, + "explicit" => GlobalParameterApplyMode::Explicit, + other => { + tracing::warn!( + name = %raw.name, + apply = %other, + "x-fern-global-parameters entry has unsupported `apply` value; \ + defaulting to auto" + ); + GlobalParameterApplyMode::Auto + } + }; + let default_yaml = raw.x_fern_default.as_ref().or(raw.default.as_ref()); + let target = raw.target.clone().unwrap_or_else(|| raw.name.clone()); + Some(GlobalParameter { + name: raw.name.clone(), + location, + target, + env: raw.env.clone(), + default: default_yaml.and_then(lower_global_parameter_default), + optional: raw.optional.unwrap_or(false), + apply, + parameter_name: raw.parameter_name.clone(), + docs: raw.docs.clone(), + }) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-groups +// --------------------------------------------------------------------------- + +/// Lower the document-root `x-fern-groups` block into the canonical +/// [`SdkGroupInfo`] discovery type, keyed by the kebab-cased group +/// identifier so it matches the resource-tree keys built from +/// `x-fern-sdk-group-name`. +/// +/// Mirrors fern's `getFernGroups.ts` / `SdkGroupInfo` IR shape +/// (`{ summary?, description? }`). Entries are kept verbatim — fern +/// does not invent additional fields, and neither do we. Empty +/// entries (both fields `None`) are preserved so the lookup tells +/// "no metadata" from "explicitly empty metadata", though both +/// render the same in `--help` today. +fn lower_fern_groups(raws: &HashMap) -> HashMap { + raws.iter() + .map(|(key, raw)| { + ( + camel_to_kebab(key), + SdkGroupInfo { + summary: raw.summary.clone(), + description: raw.description.clone(), + }, + ) + }) + .collect() +} + +// --------------------------------------------------------------------------- +// x-fern-sdk-variables +// --------------------------------------------------------------------------- + +/// Lower the spec-root `x-fern-sdk-variables` block into a flat list of +/// [`SdkVariable`] entries. Mirrors Fern's openapi-ir-parser +/// `getVariableDefinitions.ts`: each variable is keyed by name, declares +/// a schema with `type` and optional `description`, and is only honored +/// when `type` is `string`. Non-string entries are logged and dropped so +/// the rest of the spec still loads — matching the upstream importer's +/// `Variable has unsupported schema` behavior without failing +/// the whole spec load (the CLI is intentionally permissive). +fn parse_sdk_variables(mapping: Option<&serde_yaml::Mapping>) -> Vec { + let Some(mapping) = mapping else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (name_val, schema_val) in mapping { + let name = match name_val.as_str() { + Some(s) => s.to_string(), + None => { + tracing::warn!( + "x-fern-sdk-variables entry has non-string key {:?}; skipping", + name_val + ); + continue; + } + }; + let schema_map = match schema_val.as_mapping() { + Some(m) => m, + None => { + tracing::warn!( + "x-fern-sdk-variables entry '{name}' is not an object; skipping" + ); + continue; + } + }; + let ty = schema_map + .get(serde_yaml::Value::String("type".into())) + .and_then(|v| v.as_str()) + .unwrap_or("string") + .to_string(); + if ty != "string" { + tracing::warn!( + "x-fern-sdk-variables entry '{name}' has unsupported type '{ty}'; \ + only string variables are supported today (skipping)" + ); + continue; + } + let description = schema_map + .get(serde_yaml::Value::String("description".into())) + .and_then(|v| v.as_str()) + .map(str::to_string); + out.push(SdkVariable { + name, + ty, + description, + }); + } + out +} + +// --------------------------------------------------------------------------- +// Parameter conversion +// --------------------------------------------------------------------------- + +fn convert_parameter( + param: &OpenApiParameter, + ref_site_default: Option<&serde_yaml::Value>, +) -> (String, MethodParameter) { + let (param_type, enum_values, schema_default, format, fern_enum, minimum, maximum) = match ¶m.schema { + Some(s) => ( + s.schema_type.clone(), + s.enum_values.clone(), + s.default.as_ref(), + s.format.clone(), + convert_fern_enum(s.x_fern_enum.as_ref()), + s.minimum, + s.maximum, + ), + None => (None, None, None, None, None, None, None), + }; + + // `x-fern-default` is the only source of a client-side default — + // i.e. a value the CLI will (a) advertise in `--help` via clap's + // `[default: ...]` and (b) substitute into the outgoing request + // when the user omits the flag. Within the extension, ref-site wins + // over the resolved component parameter, mirroring fern's + // openapi-ir-parser precedence: + // getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT) + let client_yaml_default: Option<&serde_yaml::Value> = + ref_site_default.or(param.x_fern_default.as_ref()); + let default_value = client_yaml_default.and_then(yaml_value_to_json); + + // The OpenAPI standard `default:` keyword on a parameter's schema + // describes server-side behavior — it tells the client what the API + // will do if the value is omitted, not what the client should send. + // We surface it in `--help` as a documentation hint only. + // + // When `x-fern-default` is present it supersedes the documentation + // hint for display too (showing two different defaults would confuse + // users), so we drop the schema default in that case. + let documentation_default_value = if default_value.is_some() { + None + } else { + schema_default.and_then(yaml_value_to_json) + }; + + // Operation-level `x-fern-availability` wins; otherwise fall back to + // OpenAPI's standard `deprecated: true` flag so flags marked deprecated + // in the source spec still surface a `[DEPRECATED]` badge in `--help`. + let availability = match param.x_fern_availability { + Some(a) => Some(a), + None if param.deprecated => Some(Availability::Deprecated), + None => None, + }; + + // `x-fern-sdk-variable` is only honored on `in: path` parameters — + // Fern's IR drops references on query/header/cookie params with a + // log line, and so do we (the parameter still surfaces as a normal + // per-op flag). + let variable_reference = match param.x_fern_sdk_variable.as_deref() { + Some(name) if param.location.as_deref() == Some("path") => Some(name.to_string()), + Some(name) => { + tracing::warn!( + "x-fern-sdk-variable '{name}' on non-path parameter '{}' is ignored", + param.name + ); + None + } + None => None, + }; + + let mp = MethodParameter { + param_type, + description: param.description.clone(), + location: param.location.clone(), + required: param.required, + format, + default_value, + documentation_default_value, + enum_values, + minimum, + maximum, + style: param.style.clone(), + explode: param.explode, + deprecated: param.deprecated, + availability, + fern_enum, + variable_reference, + ..Default::default() + }; + + (param.name.clone(), mp) +} + +/// Lower the raw YAML `x-fern-enum` map into the internal representation. +/// Drops entries whose `name` and `description` are both empty/whitespace +/// so downstream clap rendering doesn't emit blank labels or help text. +/// Returns `None` if the extension is absent or every entry was empty — +/// `None` is the signal cli-sdk uses to mean "fall back to wire values". +fn convert_fern_enum( + raw: Option<&HashMap>, +) -> Option> { + let raw = raw?; + let normalize = |s: &Option| -> Option { + s.as_ref().and_then(|v| { + let t = v.trim(); + if t.is_empty() { + None + } else { + Some(t.to_string()) + } + }) + }; + let mut out: HashMap = HashMap::new(); + for (wire, entry) in raw { + let display_name = normalize(&entry.name); + let description = normalize(&entry.description); + if display_name.is_none() && description.is_none() { + continue; + } + out.insert( + wire.clone(), + crate::openapi::discovery::FernEnumValue { + display_name, + description, + }, + ); + } + if out.is_empty() { None } else { Some(out) } +} + +/// Convert a `serde_yaml::Value` into a `serde_json::Value` for storage on +/// `MethodParameter::default_value` (from `x-fern-default`) and +/// `MethodParameter::documentation_default_value` (from the standard +/// OpenAPI `default:` keyword). Mirrors YAML's scalar coverage so a +/// `100` keeps its integer type, `true` keeps its boolean type, and +/// `"abc"` stays a string. Tagged values are unwrapped; `~`/`null` +/// collapses to `Value::Null`. +fn yaml_value_to_json(v: &serde_yaml::Value) -> Option { + match v { + serde_yaml::Value::Null => Some(serde_json::Value::Null), + serde_yaml::Value::Bool(b) => Some(serde_json::Value::Bool(*b)), + serde_yaml::Value::Number(n) => { + if let Some(u) = n.as_u64() { + Some(serde_json::Value::Number(u.into())) + } else if let Some(i) = n.as_i64() { + Some(serde_json::Value::Number(i.into())) + } else if let Some(f) = n.as_f64() { + serde_json::Number::from_f64(f).map(serde_json::Value::Number) + } else { + None + } + } + serde_yaml::Value::String(s) => Some(serde_json::Value::String(s.clone())), + serde_yaml::Value::Sequence(seq) => Some(serde_json::Value::Array( + seq.iter().filter_map(yaml_value_to_json).collect(), + )), + serde_yaml::Value::Mapping(map) => { + let mut obj = serde_json::Map::new(); + for (k, val) in map { + let key = match k { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other).ok()?.trim().to_string(), + }; + if let Some(jv) = yaml_value_to_json(val) { + obj.insert(key, jv); + } + } + Some(serde_json::Value::Object(obj)) + } + serde_yaml::Value::Tagged(t) => yaml_value_to_json(&t.value), + } +} + +fn resolve_parameter<'a>( + por: &'a OpenApiParamOrRef, + components: &'a Option, +) -> Option<&'a OpenApiParameter> { + match por { + OpenApiParamOrRef::Inline(p) => Some(p.as_ref()), + OpenApiParamOrRef::Ref { ref_path, .. } => { + let name = strip_ref_prefix(ref_path); + components + .as_ref() + .and_then(|c| c.parameters.get(&name)) + } + } +} + +/// Resolve the effective `x-fern-parameter-name` for a parameter using +/// the same precedence as `x-fern-ignore`: a value placed at the +/// **ref-site** object (alongside `$ref`) wins over the value on the +/// **resolved component parameter**. Inline parameters short-circuit to +/// their own value. Returns `None` when no alias is set. +/// +/// Implements the same semantics as fern's openapi-ir-parser +/// (`getParameterName.ts` + the `??` chain used for `x-fern-ignore`): +/// ```ts +/// const alias = +/// getExtension(parameter, PARAMETER_NAME) ?? +/// getExtension(resolvedParameter, PARAMETER_NAME); +/// ``` +fn resolve_parameter_display_name( + por: &OpenApiParamOrRef, + components: &Option, +) -> Option { + match por { + OpenApiParamOrRef::Inline(p) => p.x_fern_parameter_name.clone(), + OpenApiParamOrRef::Ref { + x_fern_parameter_name: ref_site, + .. + } => { + let resolved = resolve_parameter(por, components) + .and_then(|p| p.x_fern_parameter_name.clone()); + ref_site.clone().or(resolved) + } + } +} + +/// Resolve the effective `x-fern-ignore` value for a parameter, mirroring +/// fern's precedence: a value on the **ref-site object** (placed next to +/// `$ref`) wins over the value on the **resolved component parameter**. +/// Inline parameters are a single site, so they short-circuit. Returns +/// `false` when no flag is set at any level. +/// +/// Implements the same semantics as fern's openapi-ir-parser: +/// ```ts +/// const shouldIgnore = +/// getExtension(parameter, IGNORE) ?? +/// getExtension(resolvedParameter, IGNORE); +/// ``` +fn parameter_should_ignore( + por: &OpenApiParamOrRef, + components: &Option, +) -> bool { + match por { + OpenApiParamOrRef::Inline(p) => p.x_fern_ignore.unwrap_or(false), + OpenApiParamOrRef::Ref { + x_fern_ignore: ref_site, + .. + } => { + let resolved = resolve_parameter(por, components).and_then(|p| p.x_fern_ignore); + ref_site.or(resolved).unwrap_or(false) + } + } +} + +// --------------------------------------------------------------------------- +// Core conversion +// --------------------------------------------------------------------------- + +/// Load and convert an OpenAPI 3.0 YAML spec into the internal `RestDescription`. +pub fn load_openapi_spec(yaml_str: &str, cli_name: &str) -> Result { + let value: serde_yaml::Value = serde_yaml::from_str(yaml_str) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + load_openapi_spec_from_value(value, cli_name) +} + +/// Load and convert an OpenAPI spec from a pre-parsed `serde_yaml::Value`. +/// +/// This is the workhorse behind both [`load_openapi_spec`] (plain string) and +/// the overrides path where a base spec and override YAML are deep-merged into +/// a single `Value` before deserialization. +pub fn load_openapi_spec_from_value( + value: serde_yaml::Value, + cli_name: &str, +) -> Result { + let spec: OpenApiSpec = serde_yaml::from_value(value) + .map_err(|e| CliError::Discovery(format!("Failed to parse OpenAPI spec: {e}")))?; + + let root_url = spec + .servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_default(); + + // Lower the spec's top-level `servers:` array into the internal + // representation. Order is preserved so callers can rely on + // "first server is the default" — the same rule that + // populates `root_url` above. + let top_level_servers: Vec = spec + .servers + .iter() + .map(OpenApiServer::to_discovery_server) + .collect(); + + // Convert component schemas. + // + // TODO(FER-9864): mirror fern's component-schema + property-level + // `x-fern-ignore` here once body fields surface as CLI flags. Fern's + // openapi-ir-parser drops ignored schemas in `convertSchemas.ts` and + // ignored properties in `convertObject.ts`; the CLI today only exposes + // operations + parameters, so those levels are a no-op for now and + // intentionally left unhandled. + let schemas: HashMap = spec + .components + .as_ref() + .map(|c| { + c.schemas + .iter() + .map(|(name, obj)| (name.clone(), convert_schema_object(obj))) + .collect() + }) + .unwrap_or_default(); + + // OpenAPI 3.1 `webhooks` describe inbound operations (server → user), + // so we capture them at parse time but do not lower them into CLI + // subcommands. A non-empty block is surfaced at debug level so users + // can see why a spec with only webhooks produces no commands. + if !spec.webhooks.is_empty() { + tracing::debug!( + "Spec declares {} webhook(s); webhooks are inbound and not lowered to CLI subcommands.", + spec.webhooks.len(), + ); + } + + // Lower components.securitySchemes to discovery types + let security_schemes: HashMap = spec + .components + .as_ref() + .map(|c| { + c.security_schemes + .iter() + .map(|(name, raw)| (name.clone(), lower_security_scheme(raw))) + .collect() + }) + .unwrap_or_default(); + + // Detect pagination token parameter name from components/parameters + let (pagination_query_param, pagination_response_path) = detect_pagination_config(&spec); + + // Normalize `x-fern-base-path`: trim ASCII whitespace and treat an empty + // string as absent so downstream slash-joining doesn't have to worry about + // a degenerate "" case. Leading/trailing slashes are preserved here — + // `build_url` is what normalizes them into exactly one slash between + // segments, so we don't lose authoring intent at parse time. + let base_path = normalize_base_path(spec.x_fern_base_path.as_deref()); + + // Lower spec-root `x-fern-idempotency-headers` into discovery types. Each + // entry will be materialized as a CLI flag on every idempotent operation + // below; non-idempotent operations never see these headers. + let idempotency_headers: Vec = spec + .x_fern_idempotency_headers + .as_ref() + .map(|raws| { + raws.iter() + .map(|raw| IdempotencyHeader { + header: raw.header.clone(), + name: raw.name.clone(), + env: raw.env.clone(), + }) + .collect() + }) + .unwrap_or_default(); + + // Lower the spec-root `x-fern-sdk-variables` block once. Variables + // surface as global flags later in `CliApp::run_async`; storing them + // on `RestDescription` keeps the parser as the single source of + // truth for both flag registration and per-operation substitution. + let sdk_variables = parse_sdk_variables(spec.x_fern_sdk_variables.as_ref()); + + // Spec-root `x-fern-retries`. Operations inherit this block when they + // either omit `x-fern-retries` or set it to `true`. Parsed once here + // so per-op resolution stays a cheap merge. + let spec_root_retries = parse_retries_value( + spec.x_fern_retries.as_ref().unwrap_or(&serde_yaml::Value::Null), + /*op_id=*/ "", + /*inherited=*/ true, + )?; + + // Lower the spec-root `x-fern-global-headers` block once. Globals + // surface as root flags in `CliApp::run_async` and are stamped on + // every outgoing request by the executor (per-operation parameters + // with the same wire-name still win). + let global_headers: Vec = spec + .x_fern_global_headers + .as_ref() + .map(|raws| lower_global_headers(raws)) + .unwrap_or_default(); + + // Lower the spec-root `x-fern-global-parameters` block once. + // Generalizes `x-fern-global-headers` to support header, query, + // body, and path locations with per-operation opt-in control. + let global_parameters: Vec = spec + .x_fern_global_parameters + .as_ref() + .map(|raws| lower_global_parameters(raws)) + .unwrap_or_default(); + + // Build a set of declared global parameter names for validating + // per-operation `x-fern-global-parameter` references. + let declared_global_param_names: std::collections::HashSet = global_parameters + .iter() + .map(|p| p.name.clone()) + .collect(); + + // Lower the document-root `x-fern-groups` extension. Keys are + // kebab-cased so they match the resource-tree keys built from + // `x-fern-sdk-group-name` further down. Mirrors fern's + // `XFernGroupsSchema` (record of `{ summary?, description? }`). + let groups: HashMap = spec + .x_fern_groups + .as_ref() + .map(lower_fern_groups) + .unwrap_or_default(); + + let mut doc = RestDescription { + name: cli_name.to_string(), + version: spec.info.version.clone(), + title: spec.info.title.clone(), + description: spec.info.description.clone(), + root_url: root_url.clone(), + servers: top_level_servers, + service_path: String::new(), + base_path, + schemas, + security_schemes, + pagination_token_query_param: pagination_query_param, + pagination_token_response_path: pagination_response_path, + idempotency_headers, + sdk_variables, + retries: spec_root_retries.clone(), + global_parameters, + global_headers, + groups, + ..Default::default() + }; + + // Spec-level security default. Inherited by every operation that + // doesn't declare its own `security:` block. An operation's + // `security: []` (explicit empty) overrides the default with anonymous. + let spec_default_security = spec.security.clone(); + + // Spec-root `x-fern-pagination`. Per-op `x-fern-pagination: true` + // inherits this block; per-op missing-or-`false` ignores it. + let spec_root_pagination = spec.x_fern_pagination.clone(); + + // Spec-root `x-fern-retries`. Per-op `x-fern-retries: true` adopts + // this block; per-op `false` or `{ disabled: true }` overrides it; + // per-op object merges over it field-by-field. + let spec_root_retries_raw = spec.x_fern_retries.clone(); + + // Build a reference to the component schemas for $ref body resolution. + let empty_component_schemas: HashMap = HashMap::new(); + let component_schemas: &HashMap = spec + .components + .as_ref() + .map(|c| &c.schemas) + .unwrap_or(&empty_component_schemas); + + // Build a reference to the component responses for $ref resolution. + let empty_component_responses: HashMap = HashMap::new(); + let component_responses: &HashMap = spec + .components + .as_ref() + .map(|c| &c.responses) + .unwrap_or(&empty_component_responses); + + // Process each path + method + #[allow(clippy::type_complexity)] + let http_methods: &[(&str, fn(&OpenApiPathItem) -> &Option)] = &[ + ("GET", |p: &OpenApiPathItem| &p.get), + ("POST", |p: &OpenApiPathItem| &p.post), + ("PUT", |p: &OpenApiPathItem| &p.put), + ("PATCH", |p: &OpenApiPathItem| &p.patch), + ("DELETE", |p: &OpenApiPathItem| &p.delete), + ]; + + for (path, path_item) in &spec.paths { + for &(http_method, accessor) in http_methods { + let operation = match accessor(path_item) { + Some(op) => op, + None => continue, + }; + + // Fern parity: `x-fern-ignore: true` drops the operation from the + // generated CLI surface entirely. The operation does not appear + // as a subcommand, in `--help`, or in completions. Log message + // mirrors fern's openapi-ir-parser wording so the two systems + // produce consistent diagnostics. + if operation.x_fern_ignore.unwrap_or(false) { + tracing::debug!( + "{} {} is marked with x-fern-ignore. Skipping.", + http_method, + path + ); + continue; + } + + // Resolve group name: prefer x-fern-sdk-group-name, fall back to first tag + let fern_group; + let tag_group; + let group_name: &Vec = match &operation.x_fern_sdk_group_name { + Some(g) if !g.is_empty() => g, + _ => match operation.tags.as_ref().and_then(|t| t.first()) { + Some(tag) => { + tag_group = vec![tag.clone()]; + &tag_group + } + None => { + // Fall back to first path segment as group + let segment = path + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("default") + .to_string(); + fern_group = vec![segment]; + &fern_group + } + }, + }; + + // Resolve method name: prefer x-fern-sdk-method-name, fall back to operationId or http+path. + // When the group came from a tag (no x-fern-sdk-group-name), strip + // tag tokens that prefix the operationId so e.g. `Customers` tag + // + `customersList` operation → method `list` rather than + // `customers-list`. Mirrors Fern's OpenAPI importer. + let method_name = match &operation.x_fern_sdk_method_name { + Some(m) => m.clone(), + None => match &operation.operation_id { + Some(id) => { + let stripped = if operation.x_fern_sdk_group_name.is_none() { + match operation.tags.as_ref().and_then(|t| t.first()) { + Some(tag) => strip_tag_prefix(id, tag), + None => id.clone(), + } + } else { + id.clone() + }; + camel_to_kebab(&stripped) + } + None => format!( + "{}-{}", + http_method.to_lowercase(), + path.trim_start_matches('/').replace('/', "-") + ), + }, + }; + + // Collect parameters (path-level + operation-level). Parameters + // marked `x-fern-ignore: true` are dropped — they don't surface + // as CLI flags and aren't sent in the outgoing request. + // + // The flag is read with fern's precedence: a value placed at + // the **ref-site** object (alongside `$ref`) wins over the + // value on the resolved component parameter. This matches + // OpenAPI 3.1's allowance of sibling fields next to `$ref` and + // fern's overlay system, which routinely uses ref-site ignores. + let mut params = HashMap::new(); + for por in path_item.parameters.iter().chain(operation.parameters.iter()) { + if parameter_should_ignore(por, &spec.components) { + tracing::debug!( + "{} {} has a parameter marked with x-fern-ignore. Skipping.", + http_method, + path + ); + continue; + } + let display_name = resolve_parameter_display_name(por, &spec.components); + if let Some(p) = resolve_parameter(por, &spec.components) { + // Ref-site `x-fern-default` (placed alongside `$ref`) wins + // over the value on the resolved component parameter — + // mirrors fern's importer precedence for `getExtension`. + let ref_site_default = match por { + OpenApiParamOrRef::Ref { x_fern_default, .. } => x_fern_default.as_ref(), + OpenApiParamOrRef::Inline(_) => None, + }; + let (name, mut mp) = convert_parameter(p, ref_site_default); + mp.display_name = display_name; + params.insert(name, mp); + } + } + + // Handle request body — also harvests body-located parameters so + // the command builder can render per-field flags alongside `--json`. + let (request, binary_request_body, body_encoding, body_params, multipart_fields) = + extract_request_body( + &operation.request_body, + operation.operation_id.as_deref().unwrap_or("unknown"), + &mut doc.schemas, + component_schemas, + ); + + // Extract the primary success response schema. Inline response + // schemas are registered in doc.schemas under a synthetic + // `{operation_id}_response` name so the help layer can look + // them up uniformly with named ($ref'd) responses. + let response = extract_response( + &operation.responses, + operation.operation_id.as_deref().unwrap_or("unknown"), + &mut doc.schemas, + component_responses, + ); + + // Skip body fields whose names collide with existing path/query/header + // params — those win, since the spec's `parameters` array is the + // canonical source for non-body inputs. + for (name, param) in body_params { + params.entry(name).or_insert(param); + } + + let description = operation + .summary + .clone() + .or_else(|| operation.description.clone()); + + let method_root_url = operation.servers + .first() + .map(|s| s.url.clone()) + .unwrap_or_else(|| root_url.clone()); + + // Per-op `servers:` overrides replace the global default for + // this operation. Lower them into the internal representation + // so the executor can route the global `--server ` flag + // against per-op named entries before falling back to + // `method_root_url` (the first per-op server). + let method_servers: Vec = operation + .servers + .iter() + .map(OpenApiServer::to_discovery_server) + .collect(); + + // OpenAPI inheritance: operation-level `security` (including an + // explicit empty array) takes precedence; otherwise inherit the + // spec-level default; if neither is present the operation has no + // declared policy. + let security_requirements = match &operation.security { + Some(reqs) => Some(reqs.clone()), + None => spec_default_security.clone(), + }; + + let pagination = resolve_pagination_extension( + operation.x_fern_pagination.as_ref(), + spec_root_pagination.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + let retries = resolve_retries_extension( + operation.x_fern_retries.as_ref(), + spec_root_retries_raw.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + // `x-fern-availability` wins; otherwise fall back to OpenAPI's + // standard `deprecated: true` flag so deprecated ops still get + // a `[DEPRECATED]` badge without requiring the extension. + let availability = match operation.x_fern_availability { + Some(a) => Some(a), + None if operation.deprecated => Some(Availability::Deprecated), + None => None, + }; + + let idempotent = operation.x_fern_idempotent.unwrap_or(false); + + // `x-fern-cli-idempotency: false` explicitly disables the + // auto-generated Idempotency-Key header on this operation. + let no_auto_idempotency_key = operation + .x_fern_cli_idempotency + .map(|v| !v) + .unwrap_or(false); + + // `x-fern-audiences` is an array of strings; missing means + // `[]`. Stored verbatim so the command-tree filter can + // mirror fern's `some(...)` membership check exactly. See + // discovery.rs `RestMethod::audiences` for the rationale on + // why this is parser-recorded but only consumed at the + // command-tree layer. + let audiences = operation.x_fern_audiences.clone().unwrap_or_default(); + + // Materialize idempotency-header flags on idempotent operations + // ONLY. Each spec-root `x-fern-idempotency-headers` entry becomes + // a synthetic header MethodParameter so the existing + // header-parameter pathway (clap flag → executor request + // header) handles the value. Non-idempotent siblings get no + // such parameter and therefore never send these headers on the + // wire, even if the user passes the flag explicitly (clap + // rejects it as unknown). + if idempotent { + inject_idempotency_header_params(&mut params, &doc.idempotency_headers); + } + + let return_value = operation + .x_fern_sdk_return_value + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let streaming = parse_streaming_extension( + operation.x_fern_streaming.as_ref(), + operation.operation_id.as_deref().unwrap_or("unknown"), + )?; + + let has_binary_response = response_has_binary_media_type(&operation.responses, component_responses); + + // Mutual exclusivity: an operation that's both streamed and + // paginated is incoherent — pagination drives a loop of + // requests against fully-buffered responses, while + // streaming consumes a single open response incrementally. + // The upstream Fern IR doesn't generate a meaningful + // combination either; mirror that by failing at parse time + // so spec authors get a single clear error instead of an + // ambiguous runtime fallback. + if streaming.is_some() && pagination.is_some() { + return Err(CliError::Discovery(format!( + "Operation '{}' declares both `x-fern-streaming` and \ + `x-fern-pagination`, which are mutually exclusive. Streaming \ + operations open a single long-lived response; paginated \ + operations issue multiple requests against unary responses.", + operation.operation_id.as_deref().unwrap_or("unknown"), + ))); + } + + + // Per-operation `x-fern-global-parameter` opt-in. Validate + // that every referenced name is declared in the spec-root + // `x-fern-global-parameters`. Unknown names are logged and + // dropped so a typo doesn't silently fail to inject. + let global_parameter_opt_ins: Vec = operation + .x_fern_global_parameter + .as_ref() + .map(|names| { + names + .iter() + .filter(|n| { + if declared_global_param_names.contains(n.as_str()) { + true + } else { + tracing::warn!( + operation = operation.operation_id.as_deref().unwrap_or("unknown"), + param = %n, + "x-fern-global-parameter references undeclared \ + global parameter; ignoring" + ); + false + } + }) + .cloned() + .collect() + }) + .unwrap_or_default(); + + let rest_method = RestMethod { + id: operation.operation_id.clone(), + description, + http_method: http_method.to_string(), + path: path.clone(), + parameters: params, + request, + response, + root_url: method_root_url, + servers: method_servers, + binary_request_body, + multipart_fields, + body_encoding, + security_requirements, + pagination, + availability, + idempotent, + no_auto_idempotency_key, + return_value, + streaming, + retries, + audiences, + has_binary_response, + global_parameter_opt_ins, + ..Default::default() + }; + + // Walk group_name to create/find nested resources + let kebab_groups: Vec = + group_name.iter().map(|g| camel_to_kebab(g)).collect(); + + insert_method_into_resources(&mut doc.resources, &kebab_groups, &method_name, rest_method); + } + } + + // Fern parity: if every operation under a path/group was ignored, prune + // the now-empty group so it doesn't appear as a subcommand with no + // leaves in `--help` or completions. + prune_empty_resources(&mut doc.resources); + + Ok(doc) +} + +/// Recursively drop resources that contain no methods and no non-empty +/// nested resources. Called after all paths have been processed so that +/// `x-fern-ignore`-only paths don't leave orphan groups in the command tree. +fn prune_empty_resources(resources: &mut HashMap) { + resources.retain(|_, resource| { + prune_empty_resources(&mut resource.resources); + !resource.methods.is_empty() || !resource.resources.is_empty() + }); +} + +/// Walk the group name list to find or create nested resources and insert the method. +fn insert_method_into_resources( + resources: &mut HashMap, + groups: &[String], + method_name: &str, + method: RestMethod, +) { + if groups.is_empty() { + return; + } + + let resource = resources + .entry(groups[0].clone()) + .or_default(); + + if groups.len() == 1 { + resource.methods.insert(method_name.to_string(), method); + } else { + insert_method_into_resources(&mut resource.resources, &groups[1..], method_name, method); + } +} + +/// Extract request body info from an OpenAPI requestBody. +/// +/// Maximum recursion depth for flattening nested request body object properties +/// into dot-notation flags. Mirrors `MAX_INPUT_DEPTH` in `graphql/parser.rs`. +/// Properties at depth >= MAX_BODY_DEPTH are not flattened — `--json` remains +/// the only way to supply them. +const MAX_BODY_DEPTH: u8 = 3; + +/// Result of [`extract_request_body`], as +/// `(json_schema, binary_body, body_encoding, body_params, multipart_fields)`. +/// See the function docs for per-field semantics. +type ExtractedRequestBody = ( + Option, + Option, + BodyEncoding, + HashMap, + Vec, +); + +/// Decide whether any 2xx response declares a non-JSON content media type. +/// +/// Mirrors the runtime predicate in +/// `src/openapi/executor.rs:1973-1974` — JSON means `application/json` or +/// `text/json`; anything else (audio/*, image/*, application/octet-stream, +/// text/csv, application/pdf, …) is routed to the binary file-writing path +/// in `handle_binary_response`. We walk only 2xx and `2XX`/`2xx` status +/// codes — the binary-body affordance is for successful responses, not the +/// JSON error shapes commonly declared on 4xx/5xx of the same operation +/// (which is why the mixed-response case still surfaces `--output`). +/// +/// An empty `responses` map, or one whose 2xx entries have no `content` +/// block (e.g. `204 No Content`), returns `false` — there's no body to +/// write, so `--output` would be meaningless. +fn response_has_binary_media_type( + responses: &HashMap, + component_responses: &HashMap, +) -> bool { + for (status, response_or_ref) in responses { + if !is_success_status(status) { + continue; + } + let Some(response) = resolve_response_ref(response_or_ref, component_responses) else { + continue; + }; + let Some(content) = response.content.as_ref() else { + continue; + }; + for media_type in content.keys() { + if !is_json_media_type(media_type) { + return true; + } + } + } + false +} + +fn is_success_status(status: &str) -> bool { + // OpenAPI accepts `200`, `201`, ..., and the wildcard `2XX` / `2xx`. + // The `default` key is the catch-all (typically errors) — not a 2xx. + let s = status.trim(); + if s.eq_ignore_ascii_case("2XX") { + return true; + } + s.starts_with('2') && s.len() == 3 && s[1..].chars().all(|c| c.is_ascii_digit()) +} + +fn is_json_media_type(media_type: &str) -> bool { + // Mirror the executor's runtime predicate so the gate aligns with the + // path actually taken at request time. + media_type.contains("application/json") || media_type.contains("text/json") +} + +/// Returns `(json_schema, binary_body, body_encoding, body_params, multipart_fields)`: +/// - `json_schema`: a SchemaRef for the JSON request body (if `application/json` is declared). +/// - `binary_body`: metadata when the operation expects a raw binary body +/// (any non-JSON / non-form media type). +/// - `body_encoding`: how the request body should be serialized on the wire. +/// - `body_params`: per-field flag map; when the body is an inline object schema, +/// each property up to MAX_BODY_DEPTH is exposed as a body-located [`MethodParameter`] +/// with dotted keys for nested fields. `$ref` bodies are resolved from +/// `component_schemas` and their properties flattened with the same depth rules. +/// - `multipart_fields`: per-field metadata for `multipart/form-data` bodies. +fn extract_request_body( + request_body: &Option, + operation_id: &str, + schemas: &mut HashMap, + component_schemas: &HashMap, +) -> ExtractedRequestBody { + let Some(body) = request_body.as_ref() else { + return (None, None, BodyEncoding::Json, HashMap::new(), Vec::new()); + }; + let Some(content) = body.content.as_ref() else { + return (None, None, BodyEncoding::Json, HashMap::new(), Vec::new()); + }; + + if let Some(media) = content.get("application/json") { + if let Some(schema_obj) = media.schema.as_ref() { + if let Some(ref_path) = &schema_obj.schema_ref { + let name = strip_ref_prefix(ref_path); + let body_params = component_schemas + .get(&name) + .map(|resolved| flatten_body_params(resolved, component_schemas, 0)) + .unwrap_or_default(); + return ( + Some(SchemaRef { + schema_ref: Some(name), + ..Default::default() + }), + None, + BodyEncoding::Json, + body_params, + Vec::new(), + ); + } + + let body_params = flatten_body_params(schema_obj, component_schemas, 0); + + let synthetic_name = format!("{operation_id}_request"); + let converted = convert_schema_object(schema_obj); + schemas.insert(synthetic_name.clone(), converted); + + return ( + Some(SchemaRef { + schema_ref: Some(synthetic_name), + ..Default::default() + }), + None, + BodyEncoding::Json, + body_params, + Vec::new(), + ); + } + } + + // Handle multipart/form-data bodies. Each property in the schema + // becomes a CLI flag; file-typed fields accept a path and are streamed + // as binary parts. + if let Some(media) = content.get("multipart/form-data") { + let multipart_fields = extract_multipart_fields( + media.schema.as_ref(), + &media.encoding, + component_schemas, + operation_id, + ); + if !multipart_fields.is_empty() { + return (None, None, BodyEncoding::Json, HashMap::new(), multipart_fields); + } + } + + // No JSON or multipart body declared — check for form-urlencoded body next. + if let Some(media) = content.get("application/x-www-form-urlencoded") { + if let Some(schema_obj) = media.schema.as_ref() { + if let Some(ref_path) = &schema_obj.schema_ref { + let name = strip_ref_prefix(ref_path); + let body_params = component_schemas + .get(&name) + .map(|resolved| flatten_body_params(resolved, component_schemas, 0)) + .unwrap_or_default(); + return ( + Some(SchemaRef { + schema_ref: Some(name), + ..Default::default() + }), + None, + BodyEncoding::FormUrlEncoded, + body_params, + Vec::new(), + ); + } + + let body_params = flatten_body_params(schema_obj, component_schemas, 0); + + let synthetic_name = format!("{operation_id}_request"); + let converted = convert_schema_object(schema_obj); + schemas.insert(synthetic_name.clone(), converted); + + return ( + Some(SchemaRef { + schema_ref: Some(synthetic_name), + ..Default::default() + }), + None, + BodyEncoding::FormUrlEncoded, + body_params, + Vec::new(), + ); + } + } + + // No JSON, multipart, or form body — look for a binary content type. + // `multipart/form-data` and `application/x-www-form-urlencoded` are + // explicitly excluded (handled above). + let Some((content_type, media)) = content.iter().find(|(ct, _)| { + let ct = ct.as_str(); + ct != "application/x-www-form-urlencoded" && ct != "multipart/form-data" + }) else { + return (None, None, BodyEncoding::Json, HashMap::new(), Vec::new()); + }; + + let is_binary_format = media + .schema + .as_ref() + .and_then(|s| s.format.as_deref()) + .map(|f| f == "binary") + .unwrap_or(false); + + let flag_name = body + .x_fern_parameter_name + .as_deref() + .map(camel_to_kebab) + .unwrap_or_else(|| { + if is_binary_format { + "file".to_string() + } else { + "body".to_string() + } + }); + + ( + None, + Some(BinaryRequestBody { + content_type: content_type.clone(), + flag_name, + }), + BodyEncoding::Json, + HashMap::new(), + Vec::new(), + ) +} + +/// Resolve a response-level `$ref` or return the inline response directly. +fn resolve_response_ref<'a>( + r: &'a OpenApiResponseOrRef, + component_responses: &'a HashMap, +) -> Option<&'a OpenApiResponse> { + match r { + OpenApiResponseOrRef::Inline(resp) => Some(resp), + OpenApiResponseOrRef::Ref { ref_path } => { + let name = strip_ref_prefix(ref_path); + component_responses.get(&name) + } + } +} + +/// Pick the response entry for the primary success: numerically-lowest 2xx +/// status code, falling back to `default`. +fn select_primary_response<'a>( + responses: &'a HashMap, + component_responses: &'a HashMap, +) -> Option<&'a OpenApiResponse> { + let mut twoxx: Vec<(u16, &str, &OpenApiResponse)> = responses + .iter() + .filter_map(|(code, r)| { + let n = status_code_sort_key(code)?; + if (200..300).contains(&n) { + let resolved = resolve_response_ref(r, component_responses)?; + Some((n, code.as_str(), resolved)) + } else { + None + } + }) + .collect(); + if !twoxx.is_empty() { + twoxx.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1))); + return Some(twoxx[0].2); + } + responses + .get("default") + .and_then(|r| resolve_response_ref(r, component_responses)) +} + +/// Parse an OpenAPI status code key into a `u16` for band-membership checks +/// and sorting. Wildcards (`"2XX"`) map to the highest code in their band +/// so explicit codes sort before them. +fn status_code_sort_key(code: &str) -> Option { + if let Ok(n) = code.parse::() { + return Some(n); + } + let mut digits = String::with_capacity(3); + for c in code.chars() { + if c.is_ascii_digit() { + digits.push(c); + } else if c == 'X' || c == 'x' { + digits.push('9'); + } else { + return None; + } + } + if digits.is_empty() { + return None; + } + digits.parse::().ok() +} + +/// Pick the response content type: `application/json` → `*/json` → first +/// non-JSON. Sorted by key before picking for determinism. +fn select_response_content_type( + content: &HashMap, +) -> Option<(&String, &OpenApiMediaType)> { + if let Some(entry) = content.get_key_value("application/json") { + return Some(entry); + } + let mut entries: Vec<_> = content.iter().collect(); + entries.sort_by_key(|(k, _)| k.to_ascii_lowercase()); + if let Some((k, v)) = entries.iter().find(|(ct, _)| ct.eq_ignore_ascii_case("application/json")) { + return Some((*k, *v)); + } + let json_like = entries.iter().find(|(ct, _)| { + let lower = ct.to_ascii_lowercase(); + lower.ends_with("/json") || lower.ends_with("+json") + }); + if let Some((k, v)) = json_like { + return Some((*k, *v)); + } + entries.into_iter().next() +} + +/// Extract the primary success response's JSON schema as `Option`. +/// Selects the lowest 2xx status code (falling back to `default`), resolves +/// response-level `$ref`s against `components.responses`, picks the best +/// content type, and returns the schema reference. Inline schemas are +/// registered in `schemas` under a synthetic `{operation_id}_response` name. +fn extract_response( + responses: &HashMap, + operation_id: &str, + schemas: &mut HashMap, + component_responses: &HashMap, +) -> Option { + let response = select_primary_response(responses, component_responses)?; + let content = match response.content.as_ref() { + Some(c) if !c.is_empty() => c, + _ => return None, + }; + + let (content_type, media) = select_response_content_type(content)?; + + let ct_lower = content_type.to_ascii_lowercase(); + let is_event_stream = ct_lower == "text/event-stream"; + let is_json_like = ct_lower == "application/json" + || ct_lower.ends_with("/json") + || ct_lower.ends_with("+json"); + + if !is_json_like && !is_event_stream { + return None; + } + + let schema_obj = media.schema.as_ref()?; + + if let Some(ref_path) = schema_obj.schema_ref.as_ref() { + Some(SchemaRef { + schema_ref: Some(strip_ref_prefix(ref_path)), + parameter_name: None, + }) + } else { + let mut synthetic_name = format!("{operation_id}_response"); + if schemas.contains_key(&synthetic_name) { + synthetic_name = format!("{operation_id}_inline_response"); + } + let converted = convert_schema_object(schema_obj); + schemas.insert(synthetic_name.clone(), converted); + Some(SchemaRef { + schema_ref: Some(synthetic_name), + parameter_name: None, + }) + } +} + +/// Walk a `multipart/form-data` schema and emit one [`MultipartField`] per +/// property. Resolves `$ref` to `components/schemas` for the root schema +/// and for individual properties. File fields are identified by +/// `type: string, format: binary` (or legacy `type: file`). +/// +/// `encoding` is the media type's OpenAPI `encoding` object; a per-property +/// `contentType` there is the declared media type for that part and wins over +/// anything inferred from the schema. This is the OAS 3.x mechanism for, e.g., +/// declaring that a string field carries `application/json`. When it is absent +/// the field's `content_type` is left `None` and the request builder resolves +/// it — for a file part, from the file's extension. +fn extract_multipart_fields( + schema: Option<&OpenApiSchemaObject>, + encoding: &HashMap, + component_schemas: &HashMap, + operation_id: &str, +) -> Vec { + let Some(schema) = schema else { + tracing::warn!( + operation = operation_id, + "multipart/form-data body has no schema; skipping", + ); + return Vec::new(); + }; + + // Resolve top-level $ref. + let resolved = if let Some(ref_path) = &schema.schema_ref { + let name = strip_ref_prefix(ref_path); + match component_schemas.get(&name) { + Some(r) => r, + None => { + tracing::warn!( + operation = operation_id, + schema_ref = name, + "unresolvable $ref for multipart body; skipping", + ); + return Vec::new(); + } + } + } else { + schema + }; + + if resolved.schema_type() != Some("object") { + tracing::warn!( + operation = operation_id, + "multipart/form-data schema is not an object; skipping", + ); + return Vec::new(); + } + + let required_set: std::collections::HashSet<&str> = + resolved.required.iter().map(String::as_str).collect(); + + let mut fields: Vec = resolved + .properties + .iter() + .map(|(name, prop)| { + let (is_file, inferred_ct) = classify_multipart_property(prop, component_schemas); + // A `contentType` in the `encoding` object wins over the + // schema-inferred default for this part. + let content_type = encoding + .get(name) + .and_then(|e| e.content_type.clone()) + .or(inferred_ct); + MultipartField { + wire_name: name.clone(), + is_file, + description: prop.description.clone(), + required: required_set.contains(name.as_str()), + content_type, + } + }) + .collect(); + fields.sort_by(|a, b| a.wire_name.cmp(&b.wire_name)); + fields +} + +/// How deep the multipart classifier will unwrap nested `anyOf`/`oneOf`/`allOf` +/// composition. A cyclic composition chain (`A: {anyOf: [$ref B]}`, +/// `B: {anyOf: [$ref A]}`) would otherwise recurse until the stack overflows — +/// and this loader runs at CLI startup against the baked spec, so an overflow +/// aborts the customer's binary on every invocation rather than failing at +/// generate time. Same fail-closed posture as [`MAX_BODY_DEPTH`]. +const MAX_MULTIPART_COMPOSITION_DEPTH: u8 = 4; + +/// Determine whether a multipart property is a file upload, and its content type +/// **if the schema pins one**. +/// +/// A file field returns `None`: the schema says `format: binary`, which conveys +/// "these are opaque bytes", not "label them `application/octet-stream`". +/// Returning a concrete type here would be indistinguishable downstream from a +/// type the spec actually declared via `encoding`, and it would win over the +/// media type inferred from the file's extension — labelling every upload +/// `application/octet-stream` and getting it rejected by servers that validate a +/// part's media type. Resolution is left to the request builder +/// (`file_part_mime`): declared `encoding` → extension → octet-stream. +fn classify_multipart_property( + prop: &OpenApiSchemaObject, + component_schemas: &HashMap, +) -> (bool, Option) { + classify_multipart_property_at_depth(prop, component_schemas, 0) +} + +/// [`classify_multipart_property`] with the composition-nesting counter that +/// bounds the `anyOf`/`oneOf`/`allOf` walk. +fn classify_multipart_property_at_depth( + prop: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, +) -> (bool, Option) { + // Resolve $ref if present. + let resolved = if let Some(ref_path) = &prop.schema_ref { + let name = strip_ref_prefix(ref_path); + component_schemas.get(&name).unwrap_or(prop) + } else { + prop + }; + + let ty = resolved.schema_type(); + let fmt = resolved.format.as_deref(); + + // `type: string, format: binary` or legacy `type: file` + if (ty == Some("string") && fmt == Some("binary")) || ty == Some("file") { + return (true, None); + } + + // Array of binary files (e.g. `type: array, items: { type: string, format: binary }`) + if ty == Some("array") { + if let Some(items) = &resolved.items { + if (items.schema_type() == Some("string") + && items.format.as_deref() == Some("binary")) + || items.schema_type() == Some("file") + { + return (true, None); + } + } + } + + // Composition wrapping a binary schema — the canonical shape for an + // *optional* file, `anyOf: [{type: string, format: binary}, {type: null}]`. + // Without unwrapping, an optional upload has no top-level `type`/`format` + // and would be mistaken for a text part (the filename sent as a string). + // Classify as a file when any non-null branch is itself a file; the content + // type comes from the first file branch. Bounded by + // `MAX_MULTIPART_COMPOSITION_DEPTH` so a cyclic `$ref` composition chain + // fails closed (text part) instead of overflowing the stack. + if depth < MAX_MULTIPART_COMPOSITION_DEPTH { + for branch in resolved + .any_of + .iter() + .chain(resolved.one_of.iter()) + .chain(resolved.all_of.iter()) + { + if is_null_sentinel(branch) { + continue; + } + let (branch_is_file, branch_ct) = + classify_multipart_property_at_depth(branch, component_schemas, depth + 1); + if branch_is_file { + return (true, branch_ct); + } + } + } + + (false, None) +} + +/// Recursively walk an object schema and emit one body-located [`MethodParameter`] +/// per property, up to `MAX_BODY_DEPTH` levels deep. Nested object properties +/// use dotted keys (e.g. `"name.first"`). Array properties set `repeated: true` +/// so the command builder renders `ArgAction::Append`. Read-only properties are +/// skipped. Non-object schemas at the root return an empty map. +fn flatten_body_params( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, +) -> HashMap { + flatten_body_params_prefix(schema, component_schemas, depth, "") +} + +/// True when the schema admits JSON `null` *and* its base type is a scalar +/// the CLI lowers to a single flag (`string` / `integer` / `number` / +/// `boolean`). Composite types (`array`, `object`) stay false even when the +/// schema marks them nullable — see ADR-0003 for why the null-sentinel +/// surface is scalar-only. +fn is_scalar_nullable(obj: &OpenApiSchemaObject) -> bool { + if !obj.is_nullable() { + return false; + } + matches!( + obj.schema_type(), + Some("string") | Some("integer") | Some("number") | Some("boolean"), + ) +} + +/// Recognize the "nullable union" composition shape from ADR-0005: +/// `oneOf` or `anyOf` with **exactly one** null-sentinel branch and **all +/// other branches** reducing to the same scalar base type. Returns +/// `Some(base_type)` on a match so the caller can promote the schema to a +/// nullable scalar — same downstream path as ADR-0003's intrinsic +/// nullability — or `None` when the composition is a true union, doesn't +/// have a null branch, or mixes scalar types. +/// +/// Non-null branches may be inline (`{type: scalar}`) or a `$ref` resolving +/// through `component_schemas` to a scalar component. `allOf` is never +/// promoted (intersection with null has no inhabitants). +fn recognize_nullable_union( + obj: &OpenApiSchemaObject, + component_schemas: &HashMap, +) -> Option<&'static str> { + // Pick the composition list. `oneOf` and `anyOf` are treated + // identically for nullable-union purposes — the JSON Schema distinction + // (exactly-one vs at-least-one branch matches) is irrelevant when the + // shape is `T | null`. See ADR-0005, "Negative" #3. + let branches: &[OpenApiSchemaObject] = if !obj.one_of.is_empty() { + &obj.one_of + } else if !obj.any_of.is_empty() { + &obj.any_of + } else { + return None; + }; + + if branches.len() < 2 { + return None; + } + + let mut null_count: usize = 0; + let mut base_type: Option<&'static str> = None; + + for branch in branches { + if is_null_sentinel(branch) { + null_count += 1; + continue; + } + let resolved = resolve_branch_scalar_type(branch, component_schemas)?; + match base_type { + None => base_type = Some(resolved), + Some(existing) if existing == resolved => {} + // Mixed scalar types — true union, not nullable. Bail. + Some(_) => return None, + } + } + + // Exactly one null branch. Multiple nulls is a malformed spec; zero is + // not a nullable union at all. + if null_count != 1 { + return None; + } + base_type +} + +/// True when this composition branch is the "null branch" of a nullable +/// union: either `{type: 'null'}` / `{type: ['null']}` (3.1) or a bare +/// `{nullable: true}` with no other type-bearing fields (3.0 idiom). +fn is_null_sentinel(obj: &OpenApiSchemaObject) -> bool { + if obj.schema_ref.is_some() { + return false; + } + if obj.schema_type() == Some("null") { + return true; + } + // 3.0 idiom: a branch with just `nullable: true` and no concrete type. + obj.is_nullable() && obj.schema_type().is_none() +} + +/// If `branch` resolves to one of the four scalar base types the null +/// sentinel surface supports, return that type. `$ref` branches resolve +/// one level through `component_schemas` — nested resolution (ref → ref → +/// scalar) is not supported; that pattern is rare and adds graph-walking +/// complexity to a recognizer that should fail-closed. +fn resolve_branch_scalar_type( + branch: &OpenApiSchemaObject, + component_schemas: &HashMap, +) -> Option<&'static str> { + let schema = if let Some(ref_path) = &branch.schema_ref { + let name = strip_ref_prefix(ref_path); + component_schemas.get(&name)? + } else { + branch + }; + match schema.schema_type()? { + "string" => Some("string"), + "integer" => Some("integer"), + "number" => Some("number"), + "boolean" => Some("boolean"), + _ => None, + } +} + +/// Maximum depth of `$ref` chain resolution. Prevents infinite loops from +/// cyclic `$ref` chains (e.g. `A: {$ref: B}`, `B: {$ref: A}`). +const MAX_REF_CHAIN_DEPTH: u8 = 8; + +/// Follow a `$ref` chain through `component_schemas` until reaching a +/// terminal schema (one without a `$ref`). Returns `None` if the chain +/// is unresolvable or exceeds `MAX_REF_CHAIN_DEPTH`. +fn resolve_ref_chain<'a>( + schema: &'a OpenApiSchemaObject, + component_schemas: &'a HashMap, +) -> Option<&'a OpenApiSchemaObject> { + let mut current = schema; + for _ in 0..MAX_REF_CHAIN_DEPTH { + match ¤t.schema_ref { + Some(ref_path) => { + let name = strip_ref_prefix(ref_path); + current = component_schemas.get(&name)?; + } + None => return Some(current), + } + } + if current.schema_ref.is_none() { + return Some(current); + } + tracing::warn!("$ref chain exceeded {MAX_REF_CHAIN_DEPTH} levels; likely cyclic"); + None +} + +/// Recognize a `oneOf` / `anyOf` union where one branch is a scalar type `T` +/// and another is `type: array` with `items.type: T`. This pattern +/// (e.g. `Addresses: oneOf [string, array]`) should surface as a +/// repeated flag so the CLI accepts both single values and JSON arrays. +/// Returns `Some(element_type)` when the union matches, `None` otherwise. +fn recognize_scalar_or_array_union<'a>( + obj: &'a OpenApiSchemaObject, + component_schemas: &'a HashMap, +) -> Option<&'a str> { + let branches: &[OpenApiSchemaObject] = if !obj.one_of.is_empty() { + &obj.one_of + } else if !obj.any_of.is_empty() { + &obj.any_of + } else { + return None; + }; + + if branches.len() < 2 { + return None; + } + + let mut scalar_type: Option<&str> = None; + let mut array_item_type: Option<&str> = None; + + for branch in branches { + let resolved = if let Some(ref_path) = &branch.schema_ref { + let name = strip_ref_prefix(ref_path); + component_schemas.get(&name)? + } else { + branch + }; + + if is_null_sentinel(resolved) { + continue; + } + + match resolved.schema_type() { + Some("array") => { + let item_type = resolved + .items + .as_ref() + .and_then(|it| it.schema_type()); + match item_type { + Some(t) => { + if array_item_type.is_some() { + return None; // multiple array branches + } + array_item_type = Some(t); + } + None => return None, + } + } + Some(t) if t != "object" => { + if scalar_type.is_some() { + return None; // multiple scalar branches + } + scalar_type = Some(t); + } + _ => return None, + } + } + + match (scalar_type, array_item_type) { + (Some(s), Some(a)) if s == a => Some(s), + _ => None, + } +} + +/// Maximum depth of consecutive `allOf` recursion. Cyclic `$ref` chains +/// (`Foo: {allOf: [{$ref: Foo}, ...]}`) are degenerate but legal; this cap +/// stops the walk before it explodes. Distinct from `MAX_BODY_DEPTH`, +/// which counts *user-visible* nesting (dot-notation depth). `allOf` is +/// transparent to the user, so it gets its own bound — see ADR-0004. +/// +/// **Keep in sync with `MAX_ALL_OF_DEPTH_VALIDATOR` in +/// `src/openapi/executor.rs`.** The two paths can't share the constant +/// directly per AGENTS.md (parser and validator are deliberately +/// independent module surfaces) but the value must match so a spec that +/// the parser tolerates is also tolerable to the validator. +const MAX_ALL_OF_DEPTH: u8 = 8; + +/// Walk `schema.allOf` recursively and return the merged property bag +/// plus the union of every branch's `required:` array, including the +/// schema's own `properties:` and `required:`. Each `allOf` branch +/// contributes its own properties (recursively, resolving `$ref` through +/// `component_schemas`); duplicate property names follow last-branch-wins +/// with a `tracing::warn!` so silently-overlapping specs surface. +/// +/// Returns `None` when `schema.all_of` is empty — the caller iterates +/// `schema.properties` directly without paying for a HashMap clone of +/// the existing property bag. Box's ~163 operations exercise this fast +/// path on every operation whose body isn't an `allOf` composition. +fn merge_all_of_properties( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, +) -> Option<(HashMap, std::collections::HashSet)> { + if schema.all_of.is_empty() { + return None; + } + let mut props: HashMap = HashMap::new(); + let mut required: std::collections::HashSet = std::collections::HashSet::new(); + walk_all_of(&mut props, &mut required, schema, component_schemas, 0); + Some((props, required)) +} + +fn walk_all_of( + props: &mut HashMap, + required: &mut std::collections::HashSet, + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, +) { + if depth >= MAX_ALL_OF_DEPTH { + tracing::warn!( + "allOf recursion exceeded {MAX_ALL_OF_DEPTH} levels; truncating. Likely a cyclic $ref chain." + ); + return; + } + // Branches first (in declaration order); the schema's own properties + // come last so they act as the final overlay, matching the + // last-branch-wins convention from ADR-0004. + for branch in &schema.all_of { + if let Some(ref_path) = &branch.schema_ref { + let name = strip_ref_prefix(ref_path); + match component_schemas.get(&name) { + Some(resolved) => walk_all_of(props, required, resolved, component_schemas, depth + 1), + None => tracing::warn!("allOf branch references unresolvable schema: {ref_path}"), + } + } else { + walk_all_of(props, required, branch, component_schemas, depth + 1); + } + } + for (name, prop) in &schema.properties { + if props.contains_key(name) { + tracing::warn!( + "allOf merge: property '{name}' declared by multiple branches; last-branch-wins" + ); + } + props.insert(name.clone(), prop.clone()); + } + for r in &schema.required { + required.insert(r.clone()); + } +} + +fn flatten_body_params_prefix( + schema: &OpenApiSchemaObject, + component_schemas: &HashMap, + depth: u8, + prefix: &str, +) -> HashMap { + let mut out = HashMap::new(); + // Entry condition: standard object body, or a root-level `allOf:` + // composition (no `type:` declared, but the branches contribute the + // property set). Without the `allOf` clause an `allOf`-bodied + // operation would silently produce zero flags. See ADR-0004 § + // "Root-level vs property-level". + let has_all_of = !schema.all_of.is_empty(); + if depth >= MAX_BODY_DEPTH || (schema.schema_type() != Some("object") && !has_all_of) { + return out; + } + + // `merge_all_of_properties` returns `None` when there's no `allOf`, + // so the non-composition path iterates `schema.properties` directly + // without paying for a HashMap clone. See ADR-0004. + let merged = merge_all_of_properties(schema, component_schemas); + let (properties, required): ( + &HashMap, + std::collections::HashSet<&str>, + ) = match &merged { + Some((p, r)) => (p, r.iter().map(String::as_str).collect()), + None => ( + &schema.properties, + schema.required.iter().map(String::as_str).collect(), + ), + }; + + for (name, prop) in properties { + if prop.read_only { + continue; + } + let full_key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + + // $ref property: resolve from component_schemas before checking type. + // Follow ref chains (A → B → C) to reach the terminal schema. + if let Some(ref_path) = &prop.schema_ref { + let ref_name = strip_ref_prefix(ref_path); + let direct_resolved = component_schemas.get(&ref_name); + let resolved = direct_resolved.and_then(|s| resolve_ref_chain(s, component_schemas)); + if let Some(resolved) = resolved { + // Recurse for object-shaped *or* allOf-shaped resolved + // schemas — the latter is the inheritance pattern + // (`Mixin: {allOf: [Base, extras]}`) Box uses heavily. + if resolved.schema_type() == Some("object") || !resolved.all_of.is_empty() { + let nested = flatten_body_params_prefix(resolved, component_schemas, depth + 1, &full_key); + if !nested.is_empty() { + out.extend(nested); + out.insert( + full_key.clone(), + MethodParameter { + param_type: Some("object".to_string()), + location: Some("body".to_string()), + required: false, + description: prop + .description + .clone() + .or_else(|| resolved.description.clone()), + ..Default::default() + }, + ); + continue; + } + } + // Recognize oneOf/anyOf [T, array] unions and emit as a + // repeated flag so the executor JSON-parses array inputs + // instead of passing them as literal strings. + if let Some(element_type) = recognize_scalar_or_array_union(resolved, component_schemas) { + let const_default = const_default_value(resolved); + let has_null_branch = resolved.one_of.iter() + .chain(resolved.any_of.iter()) + .any(|b| { + let eff = b.schema_ref.as_ref().and_then(|r| { + component_schemas.get(&strip_ref_prefix(r)) + }).unwrap_or(b); + is_null_sentinel(eff) + }); + out.insert( + full_key, + MethodParameter { + param_type: Some(element_type.to_string()), + description: prop.description.clone().or_else(|| resolved.description.clone()), + location: Some("body".to_string()), + required: required.contains(name.as_str()) && const_default.is_none(), + format: resolved.format.clone(), + default_value: const_default, + repeated: true, + scalar_or_array: true, + nullable: resolved.is_nullable() || has_null_branch, + ..Default::default() + }, + ); + continue; + } + // Non-object ref or empty recursion — emit with resolved type. + // Promote nullable-union compositions to a scalar flag + // routed through ADR-0003's sentinel; see ADR-0005. + let promoted_scalar = recognize_nullable_union(resolved, component_schemas); + let is_array = resolved.schema_type() == Some("array"); + let const_default = const_default_value(resolved); + out.insert( + full_key, + MethodParameter { + param_type: if is_array { + Some("string".to_string()) + } else if let Some(t) = promoted_scalar { + Some(t.to_string()) + } else { + resolved.schema_type().map(str::to_string) + }, + description: prop.description.clone().or_else(|| resolved.description.clone()), + location: Some("body".to_string()), + // A `const` makes the field effectively optional: the + // value is fixed, so we auto-inject it via default_value + // when omitted. Spec's `required:` only matters when the + // user could meaningfully choose to omit a value. + required: required.contains(name.as_str()) && const_default.is_none(), + format: resolved.format.clone(), + enum_values: effective_enum_values(resolved), + default_value: const_default, + repeated: is_array, + nullable: is_scalar_nullable(resolved) || promoted_scalar.is_some(), + ..Default::default() + }, + ); + } + // Unresolvable $ref — skip rather than emitting a typeless flag. + continue; + } + + let prop_type = prop.schema_type(); + + // Nested object *or* property-level allOf: recurse to emit + // dot-notation flags. If nothing comes back (no sub-properties + // or depth limit hit), fall through to the default insert below. + if prop_type == Some("object") || !prop.all_of.is_empty() { + let nested = flatten_body_params_prefix(prop, component_schemas, depth + 1, &full_key); + if !nested.is_empty() { + out.extend(nested); + out.insert( + full_key.clone(), + MethodParameter { + param_type: Some("object".to_string()), + location: Some("body".to_string()), + required: false, + description: prop.description.clone(), + ..Default::default() + }, + ); + continue; + } + } + + // Recognize inline oneOf/anyOf [T, array] unions. + if let Some(element_type) = recognize_scalar_or_array_union(prop, component_schemas) { + let const_default = const_default_value(prop); + let has_null_branch = prop.one_of.iter() + .chain(prop.any_of.iter()) + .any(|b| { + let eff = b.schema_ref.as_ref().and_then(|r| { + component_schemas.get(&strip_ref_prefix(r)) + }).unwrap_or(b); + is_null_sentinel(eff) + }); + out.insert( + full_key, + MethodParameter { + param_type: Some(element_type.to_string()), + description: prop.description.clone(), + location: Some("body".to_string()), + required: required.contains(name.as_str()) && const_default.is_none(), + format: prop.format.clone(), + default_value: const_default, + repeated: true, + scalar_or_array: true, + nullable: prop.is_nullable() || has_null_branch, + ..Default::default() + }, + ); + continue; + } + + // Promote nullable-union compositions (`anyOf: [scalar, null]` + // or the same shape with `oneOf`) to a nullable scalar flag. + // Returns None when the composition is a true union or absent. + let promoted_scalar = recognize_nullable_union(prop, component_schemas); + let is_array = prop_type == Some("array"); + let const_default = const_default_value(prop); + out.insert( + full_key, + MethodParameter { + param_type: if is_array { + Some("string".to_string()) + } else if let Some(t) = promoted_scalar { + Some(t.to_string()) + } else { + prop_type.map(str::to_string) + }, + description: prop.description.clone(), + location: Some("body".to_string()), + required: required.contains(name.as_str()) && const_default.is_none(), + format: prop.format.clone(), + enum_values: effective_enum_values(prop), + default_value: const_default, + repeated: is_array, + nullable: is_scalar_nullable(prop) || promoted_scalar.is_some(), + ..Default::default() + }, + ); + } + out +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_convert_parameter_lowers_schema_min_max_into_method_parameter() { + // Parser must read `minimum:` / `maximum:` from the parameter's + // schema into MethodParameter — otherwise the new f64-typed + // fields are unreachable from real specs and ADR-0006's + // per-property bound promise only holds for body fields. + let raw = r#" + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + "#; + let p: OpenApiParameter = serde_yaml::from_str(raw).unwrap(); + let (name, mp) = convert_parameter(&p, None); + assert_eq!(name, "limit"); + assert_eq!(mp.minimum, Some(1.0), "minimum must lower from schema"); + assert_eq!(mp.maximum, Some(100.0), "maximum must lower from schema"); + } + + #[test] + fn test_camel_to_kebab() { + assert_eq!(camel_to_kebab("scheduledEvents"), "scheduled-events"); + assert_eq!(camel_to_kebab("eventTypes"), "event-types"); + assert_eq!(camel_to_kebab("users"), "users"); + assert_eq!(camel_to_kebab("dataCompliance"), "data-compliance"); + assert_eq!(camel_to_kebab("ABC"), "a-b-c"); + // Tags from OpenAPI specs often contain spaces or hyphens — these + // should collapse to a single hyphen, not preserve a space before + // the next word's leading character. + assert_eq!(camel_to_kebab("Channel Settings"), "channel-settings"); + assert_eq!(camel_to_kebab("Attribute Values"), "attribute-values"); + assert_eq!(camel_to_kebab("Metafields Batch"), "metafields-batch"); + assert_eq!(camel_to_kebab("foo--bar"), "foo-bar"); + assert_eq!(camel_to_kebab("CustomerList"), "customer-list"); + } + + /// Locks `build.rs::to_kebab` and `parser.rs::camel_to_kebab` to the + /// same output. They must be byte-for-byte equivalent so the smoke-test + /// constants emitted by build.rs match what the parser produces at + /// runtime. If this test fails after a build.rs edit, sync the two impls. + #[test] + fn test_build_rs_to_kebab_matches_parser_camel_to_kebab() { + // Inline copy of build.rs::to_kebab — drift here is the whole point + // of the test, so we can't just call it. + fn build_rs_to_kebab(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for ch in s.chars() { + if !ch.is_ascii_alphanumeric() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + while result.ends_with('-') { + result.pop(); + } + result + } + for case in [ + "scheduledEvents", + "Metadata taxonomies", // hit the bug that started this + "Channel Settings", + "foo--bar", + "CustomerList", + "ABC", + "with.dot.separators", + "trailing---dashes-", + "leading---dashes", + "_leading_underscore", + ] { + assert_eq!( + build_rs_to_kebab(case), + camel_to_kebab(case), + "drift between build.rs::to_kebab and parser::camel_to_kebab for input {case:?}" + ); + } + } + + #[test] + fn test_tokenize_camel_and_other() { + // camelCase: split on capitals + assert_eq!(tokenize("getCustomers"), vec!["get", "customers"]); + assert_eq!(tokenize("customersList"), vec!["customers", "list"]); + // snake_case / spaces / mixed: split on non-alphanumeric + assert_eq!(tokenize("customer_addresses"), vec!["customer", "addresses"]); + assert_eq!(tokenize("Customer Addresses"), vec!["customer", "addresses"]); + // already a single token + assert_eq!(tokenize("customers"), vec!["customers"]); + } + + #[test] + fn test_strip_tag_prefix_strips_when_op_starts_with_tag() { + // Fern parity: `Customers` tag + `customersList` operationId → `list`. + assert_eq!(strip_tag_prefix("customersList", "Customers"), "list"); + // Multi-token tag ("Customer Addresses") matches multi-token op prefix. + assert_eq!( + strip_tag_prefix("customerAddressesList", "Customer Addresses"), + "list" + ); + } + + #[test] + fn test_strip_tag_prefix_no_strip_when_no_overlap() { + // BigCommerce-style: op `getCustomers` doesn't start with tag tokens. + assert_eq!(strip_tag_prefix("getCustomers", "Customers"), "getCustomers"); + } + + #[test] + fn test_method_name_strips_tag_prefix_with_tag_grouping() { + // Tag-driven group + operationId starts with tag → method = remainder. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: customersList + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list"), "method should be `list` after strip"); + } + + #[test] + fn test_method_name_keeps_operation_id_when_no_tag_overlap() { + // BigCommerce-shape: operationId doesn't start with tag → method + // stays as full kebab'd operationId. Matches Fern's behavior. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("get-customers")); + } + + #[test] + fn test_binary_request_body_flag_name_defaults_to_file_for_format_binary() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /upload: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: uploadFile + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + let binary = upload.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "application/octet-stream"); + assert_eq!(binary.flag_name, "file"); + } + + #[test] + fn test_binary_request_body_honors_x_fern_parameter_name() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /audio: + post: + x-fern-sdk-group-name: audio + x-fern-sdk-method-name: send + operationId: sendAudio + requestBody: + x-fern-parameter-name: audioFile + content: + audio/mpeg: + schema: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let send = &doc.resources["audio"].methods["send"]; + let binary = send.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "audio/mpeg"); + assert_eq!(binary.flag_name, "audio-file"); + } + + #[test] + fn test_binary_request_body_defaults_to_body_when_not_binary_format() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /text: + post: + x-fern-sdk-group-name: text + x-fern-sdk-method-name: send + operationId: sendText + requestBody: + content: + text/plain: + schema: + type: string + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let send = &doc.resources["text"].methods["send"]; + let binary = send.binary_request_body.as_ref().unwrap(); + assert_eq!(binary.content_type, "text/plain"); + assert_eq!(binary.flag_name, "body"); + } + + #[test] + fn test_multipart_optional_file_via_anyof_null_is_classified_as_file() { + // Regression: an *optional* file (`anyOf: [{string, binary}, {null}]`) + // must be recognized as a file part, not sent as a text part (the + // filename as a string), which the server rejects with a 422. + let yaml = r#" +openapi: "3.1.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /upload-file: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: uploadFile + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [name] + properties: + name: { type: string } + file: + anyOf: + - { type: string, format: binary } + - { type: "null" } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + let file_field = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field present"); + assert!( + file_field.is_file, + "optional anyOf-null binary field must classify as a file" + ); + let name_field = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "name") + .expect("name field present"); + assert!(!name_field.is_file, "plain string field stays a text part"); + } + + #[test] + fn test_multipart_cyclic_composition_does_not_overflow_the_stack() { + // Regression: unwrapping `anyOf`/`oneOf`/`allOf` to classify optional + // files must be depth-bounded. A cyclic composition chain used to + // recurse forever and abort the process — and since the CLI loads its + // baked spec at startup, that took the customer's binary down on every + // invocation. The cyclic field must fail closed as a text part. + let yaml = r#" +openapi: "3.1.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + schemas: + A: { anyOf: [ { $ref: '#/components/schemas/B' } ] } + B: { anyOf: [ { $ref: '#/components/schemas/A' } ] } +paths: + /upload-file: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: uploadFile + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + cyclic: { $ref: '#/components/schemas/A' } + file: { type: string, format: binary } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + let cyclic = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "cyclic") + .expect("cyclic field present"); + assert!( + !cyclic.is_file, + "an unresolvable cyclic schema must fail closed as a text part" + ); + // The depth cap must not disturb classification of sibling fields. + let file_field = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field present"); + assert!(file_field.is_file); + } + + #[test] + fn test_has_binary_response_true_for_audio_2xx() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /tts: + post: + x-fern-sdk-group-name: tts + x-fern-sdk-method-name: convert + operationId: convertTts + responses: + "200": + description: ok + content: + audio/mpeg: + schema: { type: string, format: binary } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["tts"].methods["convert"]; + assert!( + convert.has_binary_response, + "audio/mpeg 2xx should flip has_binary_response on" + ); + } + + #[test] + fn test_has_binary_response_false_for_json_only_2xx() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /models: + get: + x-fern-sdk-group-name: models + x-fern-sdk-method-name: list + operationId: listModels + responses: + "200": + description: ok + content: + application/json: + schema: { type: array, items: { type: object } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let list = &doc.resources["models"].methods["list"]; + assert!( + !list.has_binary_response, + "JSON-only 2xx must NOT flip has_binary_response on" + ); + } + + #[test] + fn test_has_binary_response_mixed_binary_2xx_and_json_4xx() { + // The edge case the user called out: a 2xx that returns audio and + // a 4xx that returns JSON-shaped errors must still surface --output + // because the success path is what writes the file. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /tts: + post: + x-fern-sdk-group-name: tts + x-fern-sdk-method-name: convert + operationId: convertTts + responses: + "200": + description: ok + content: + audio/mpeg: + schema: { type: string, format: binary } + "422": + description: validation error + content: + application/json: + schema: { type: object } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let convert = &doc.resources["tts"].methods["convert"]; + assert!( + convert.has_binary_response, + "mixed (audio 2xx + JSON 4xx) must keep --output available" + ); + } + + #[test] + fn test_has_binary_response_false_for_non_json_only_on_4xx() { + // Symmetric inverse of the mixed case: a 4xx with a non-JSON shape + // does NOT, on its own, make the operation binary — only 2xx + // bodies are written via --output. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /models: + get: + x-fern-sdk-group-name: models + x-fern-sdk-method-name: list + operationId: listModels + responses: + "200": + description: ok + content: + application/json: + schema: { type: object } + "400": + description: error + content: + text/plain: + schema: { type: string } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let list = &doc.resources["models"].methods["list"]; + assert!( + !list.has_binary_response, + "text/plain on a 4xx must not flip the binary-response gate" + ); + } + + #[test] + fn test_has_binary_response_honors_2xx_wildcard() { + // OpenAPI lets specs use a `2XX` wildcard instead of a specific + // status code. The gate must treat that the same as `200`/`201`. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /download: + get: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: download + operationId: downloadFile + responses: + "2XX": + description: ok + content: + application/octet-stream: + schema: { type: string, format: binary } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let dl = &doc.resources["files"].methods["download"]; + assert!( + dl.has_binary_response, + "the 2XX wildcard must be treated as a success status" + ); + } + + #[test] + fn test_has_binary_response_false_for_empty_responses() { + // No declared response media types → can't know, default to false. + // The runtime still honors the actual Content-Type when the request + // is made, but the help surface shouldn't advertise --output + // speculatively for ops that don't declare any body shape. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /ping: + get: + x-fern-sdk-group-name: ops + x-fern-sdk-method-name: ping + operationId: ping + responses: + "204": + description: no content +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let ping = &doc.resources["ops"].methods["ping"]; + assert!( + !ping.has_binary_response, + "204 No Content has no body — --output is meaningless, gate stays off" + ); + } + + #[test] + fn test_multipart_form_data_fields_parsed() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: The file to upload + purpose: + type: string + description: Purpose of the upload + responses: { "201": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + assert_eq!(create.multipart_fields.len(), 2); + + let file_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!(file_field.is_file); + assert!(file_field.required); + assert_eq!( + file_field.description.as_deref(), + Some("The file to upload") + ); + + let purpose_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "purpose") + .expect("purpose field missing"); + assert!(!purpose_field.is_file); + assert!(!purpose_field.required); + } + + #[test] + fn test_multipart_form_data_with_ref_schema() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /files: + post: + x-fern-sdk-group-name: files + x-fern-sdk-method-name: upload + operationId: filesUpload + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FileUpload' + responses: { "200": { description: ok } } +components: + schemas: + FileUpload: + type: object + required: [content] + properties: + content: + type: string + format: binary + label: + type: string +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let upload = &doc.resources["files"].methods["upload"]; + assert_eq!(upload.multipart_fields.len(), 2); + + let content = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "content") + .expect("content field missing"); + assert!(content.is_file); + assert!(content.required); + + let label = upload + .multipart_fields + .iter() + .find(|f| f.wire_name == "label") + .expect("label field missing"); + assert!(!label.is_file); + assert!(!label.required); + } + + #[test] + fn test_multipart_encoding_content_type_overrides_inferred() { + // The OpenAPI `encoding` object pins a per-part Content-Type that + // wins over the schema-inferred default: here `metadata` is a plain + // string (would default to a text part) but is declared + // `application/json`, and the file part's octet-stream default is + // overridden to `image/png`. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + metadata: + type: string + encoding: + file: + contentType: image/png + metadata: + contentType: application/json + responses: { "201": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + + let file_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .expect("file field missing"); + assert!(file_field.is_file); + assert_eq!( + file_field.content_type.as_deref(), + Some("image/png"), + "encoding.contentType should override the octet-stream default", + ); + + let metadata_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "metadata") + .expect("metadata field missing"); + assert!(!metadata_field.is_file); + assert_eq!( + metadata_field.content_type.as_deref(), + Some("application/json"), + "a text part can carry a per-part Content-Type via encoding", + ); + } + + #[test] + fn test_multipart_file_part_defaults_to_octet_stream_without_encoding() { + // Absent an `encoding` entry, a binary part keeps the OpenAPI + // default content type and a text part stays untyped (None → + // reqwest's text/plain at send time). + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + note: + type: string + responses: { "201": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + + let file_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "file") + .unwrap(); + // `format: binary` with no `encoding` entry pins no media type. Left + // `None` so the request builder can infer it from the file's extension; + // synthesizing `application/octet-stream` here would outrank that + // inference and make uploads fail against servers that validate a part's + // media type. + assert_eq!(file_field.content_type, None); + + let note_field = create + .multipart_fields + .iter() + .find(|f| f.wire_name == "note") + .unwrap(); + assert_eq!(note_field.content_type, None); + } + + #[test] + fn test_multipart_does_not_produce_json_body() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /uploads: + post: + x-fern-sdk-group-name: uploads + x-fern-sdk-method-name: create + operationId: uploadsCreate + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let create = &doc.resources["uploads"].methods["create"]; + assert!( + create.request.is_none(), + "multipart ops should not have a JSON request schema" + ); + assert!( + create.binary_request_body.is_none(), + "multipart ops should not have a binary_request_body" + ); + assert!( + !create.multipart_fields.is_empty(), + "multipart ops should have multipart_fields" + ); + } + + #[test] + fn test_group_name_accepts_scalar_string() { + // AssemblyAI and other Fern specs write `x-fern-sdk-group-name: transcripts` + // as a bare string; the parser should accept it as a single-element list. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /transcripts: + get: + x-fern-sdk-group-name: transcripts + x-fern-sdk-method-name: list + operationId: listTranscripts + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert!(doc.resources.contains_key("transcripts")); + assert!(doc.resources["transcripts"].methods.contains_key("list")); + } + + #[test] + fn test_method_name_skips_strip_when_explicit_group_name() { + // x-fern-sdk-group-name is the source of truth; tag-driven strip is + // bypassed so the operationId surfaces verbatim. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /customers: + get: + tags: [Customers] + x-fern-sdk-group-name: ["customers"] + operationId: customersList + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!( + customers.methods.contains_key("customers-list"), + "explicit group-name disables tag-prefix strip" + ); + } + + #[test] + fn test_nested_group_names() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /parent/{id}/child: + get: + operationId: get-child + summary: Get a child resource + x-fern-sdk-group-name: + - parent + - child + x-fern-sdk-method-name: get-child + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.resources.contains_key("parent")); + let parent = &doc.resources["parent"]; + assert!(parent.methods.is_empty()); + assert!(parent.resources.contains_key("child")); + let child = &parent.resources["child"]; + assert!(child.methods.contains_key("get-child")); + } + + // ----------------------------------------------------------------- + // x-fern-ignore — operation-level + parameter-level + // ----------------------------------------------------------------- + + #[test] + fn test_x_fern_ignore_drops_operation() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + x-fern-ignore: true + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!( + !doc.resources.contains_key("users"), + "ignored operation's group should be pruned when no other ops remain" + ); + } + + #[test] + fn test_x_fern_ignore_drops_parameter() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: keep_me + in: query + schema: + type: string + - name: drop_me + in: query + x-fern-ignore: true + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + list.parameters.contains_key("keep_me"), + "non-ignored param should survive" + ); + assert!( + !list.parameters.contains_key("drop_me"), + "ignored param should be absent from operation" + ); + } + + #[test] + fn test_x_fern_ignore_mixed_path_keeps_non_ignored_ops() { + // Same path, two operations: GET is ignored, POST is kept. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + x-fern-ignore: true + responses: + '200': + description: OK + post: + operationId: users-create + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + responses: + '201': + description: Created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let users = &doc.resources["users"]; + assert!(!users.methods.contains_key("list"), "ignored op absent"); + assert!(users.methods.contains_key("create"), "non-ignored op kept"); + } + + #[test] + fn test_x_fern_ignore_prunes_empty_nested_group() { + // A nested group whose only leaf is ignored should be pruned all the + // way up — the empty parent group must not appear as a subcommand + // with no children. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /parent/child: + get: + operationId: only-op + x-fern-sdk-group-name: ["parent", "child"] + x-fern-sdk-method-name: get + x-fern-ignore: true + responses: + '200': + description: OK + /siblings: + get: + operationId: siblings-list + x-fern-sdk-group-name: ["siblings"] + x-fern-sdk-method-name: list + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!( + !doc.resources.contains_key("parent"), + "empty parent group should be pruned after only child is ignored" + ); + assert!( + doc.resources.contains_key("siblings"), + "unrelated groups must remain" + ); + } + + #[test] + fn test_x_fern_ignore_default_false_keeps_operation_and_parameter() { + // Sanity check: omitting `x-fern-ignore` keeps the operation and + // its parameters exactly as before — no behavior change for specs + // that don't use the extension. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: filter + in: query + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!(list.parameters.contains_key("filter")); + } + + #[test] + fn test_x_fern_ignore_at_parameter_ref_site_drops_parameter() { + // Fern parity: when `x-fern-ignore: true` lives on the **ref-site** + // object (alongside `$ref`), the parameter is dropped even when the + // referenced component itself has no ignore flag. Mirrors fern's + // openapi-ir-parser precedence: + // getExtension(parameter, IGNORE) ?? getExtension(resolvedParameter, IGNORE) + // — ref-site wins, fallback to resolved. OpenAPI 3.1 explicitly + // allows sibling fields next to `$ref`, and fern's overlay system + // routinely places ignores at the ref site. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Filter' + x-fern-ignore: true + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: OK +components: + parameters: + Filter: + name: filter + in: query + schema: + type: string + Cursor: + name: cursor + in: query + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + !list.parameters.contains_key("filter"), + "ref-site x-fern-ignore should drop the parameter even when the resolved component has no flag" + ); + assert!( + list.parameters.contains_key("cursor"), + "ref to a non-ignored component should still produce a parameter" + ); + } + + #[test] + fn test_x_fern_ignore_at_component_drops_parameter_via_any_ref() { + // Mirror image of the ref-site test: when the **resolved component** + // carries the ignore flag and the ref site does not, every $ref to + // that component should drop the parameter. This is the fallback + // half of fern's `??` precedence. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Legacy' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: OK +components: + parameters: + Legacy: + name: legacy + in: query + x-fern-ignore: true + schema: + type: string + Cursor: + name: cursor + in: query + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["users"].methods["list"]; + assert!( + !list.parameters.contains_key("legacy"), + "component-level x-fern-ignore should drop the parameter when reached via $ref" + ); + assert!(list.parameters.contains_key("cursor")); + } + + // ----------------------------------------------------------------- + // x-fern-parameter-name — alias the CLI flag while keeping the + // original wire name on the outgoing HTTP request. Mirrors fern's + // openapi-ir-parser `parameterNameOverride` (see + // packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/endpoint/convertParameters.ts). + // ----------------------------------------------------------------- + + #[test] + fn test_x_fern_parameter_name_inline_sets_display_name() { + // Canonical Fern example: a header parameter named `X-Fern-Version` + // is renamed to `version` on the SDK / CLI surface. The map key + // stays the wire name so the executor still sends it as a header + // with the original name. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: X-Fern-Version + in: header + x-fern-parameter-name: version + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("X-Fern-Version") + .expect("parameter should still be keyed by wire name"); + assert_eq!( + p.display_name.as_deref(), + Some("version"), + "display_name should hold the x-fern-parameter-name alias" + ); + assert_eq!(p.location.as_deref(), Some("header")); + } + + #[test] + fn test_x_fern_parameter_name_absent_leaves_display_name_none() { + // Sanity: when the extension is absent, `display_name` stays + // `None` so downstream code falls back to the wire name when + // building the CLI flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: filter + in: query + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list.parameters.get("filter").expect("filter param missing"); + assert!( + p.display_name.is_none(), + "missing x-fern-parameter-name should leave display_name = None" + ); + } + + #[test] + fn test_x_fern_parameter_name_at_ref_site_wins_over_component() { + // Ref-site precedence (matches the `??` chain fern uses for both + // x-fern-ignore and x-fern-parameter-name). The component-level + // alias is `legacyName`, but the ref-site override is `newName` + // — the ref-site value wins. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/LegacyParam' + x-fern-parameter-name: newName + responses: + '200': + description: OK +components: + parameters: + LegacyParam: + name: legacy_param + in: query + x-fern-parameter-name: legacyName + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("legacy_param") + .expect("wire name (param name) should still be the map key"); + assert_eq!( + p.display_name.as_deref(), + Some("newName"), + "ref-site x-fern-parameter-name should win over the resolved component value" + ); + } + + #[test] + fn test_x_fern_parameter_name_falls_back_to_component_when_ref_site_absent() { + // The fallback half of the `??` precedence: when the ref site has + // no alias, the resolved component's `x-fern-parameter-name` is + // used. This is the common case for shared parameter components. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/SharedHeader' + responses: + '200': + description: OK +components: + parameters: + SharedHeader: + name: X-Fern-Version + in: header + x-fern-parameter-name: version + schema: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = list + .parameters + .get("X-Fern-Version") + .expect("wire name should be the map key"); + assert_eq!( + p.display_name.as_deref(), + Some("version"), + "component-level x-fern-parameter-name should be honored when ref site has none" + ); + } + + #[test] + fn test_x_fern_parameter_name_kebab_normalization_via_commands_builder() { + // The parser stores the raw alias as-is; kebab-casing is the + // command builder's responsibility (see `to_kebab_flag` in + // src/text.rs). This test pins the parser contract: the value + // stored on `MethodParameter::display_name` must match what the + // spec wrote, so the flag-builder can canonicalize it itself. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: X-Some-Wire-Header + in: header + x-fern-parameter-name: customerAccountId + schema: + type: string + responses: + '200': + description: OK +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let list = &doc.resources["things"].methods["list"]; + let p = &list.parameters["X-Some-Wire-Header"]; + // Raw value, exactly as the spec wrote it. `to_kebab_flag` + // converts `customerAccountId` → `customer-account-id`. + assert_eq!(p.display_name.as_deref(), Some("customerAccountId")); + // And the unit test for kebab normalization itself already lives + // in `src/text.rs` — see `test_to_kebab_flag`. + assert_eq!( + crate::text::to_kebab_flag(p.display_name.as_deref().unwrap()), + "customer-account-id" + ); + } + + // ----------------------------------------------------------------- + // x-fern-default vs. OpenAPI standard `default:` + // + // We split the two sources because they mean different things: + // * `x-fern-default` is a CLIENT-SIDE default — the CLI sends it + // on the wire when the user omits the flag, and it shows in + // `--help` via clap's `[default: ...]`. Stored on + // `MethodParameter::default_value`. + // * `default:` (OpenAPI standard) is a DOCUMENTATION HINT about + // server behavior. It is rendered as ` [API default: ...]` in + // `--help` but never sent on the wire. Stored on + // `MethodParameter::documentation_default_value`. + // + // Within `x-fern-default`, fern's openapi-ir-parser precedence + // applies: ref-site beats the resolved component parameter, i.e. + // getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT). + // + // When `x-fern-default` is present, the schema `default:` is + // dropped from `documentation_default_value` too so `--help` + // doesn't render two conflicting `[default: ...]` lines. + // ----------------------------------------------------------------- + + fn fern_default_yaml(parameters_block: &str) -> String { + format!( + r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: +{parameters_block} + responses: + '200': + description: OK +"# + ) + } + + #[test] + fn test_default_value_absent_when_no_default_anywhere() { + // Sanity check: omitting both `default:` and `x-fern-default` + // leaves both fields `None` — no clap default and no help-text + // suffix get emitted. + let yaml = fern_default_yaml( + " - name: cursor\n in: query\n schema:\n type: string", + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let cursor = doc.resources["users"].methods["list"] + .parameters + .get("cursor") + .unwrap(); + assert!(cursor.default_value.is_none()); + assert!(cursor.documentation_default_value.is_none()); + } + + #[test] + fn test_standard_openapi_default_lowers_as_documentation_only() { + // OpenAPI's standard `default:` describes server behavior and is + // doc-only for the CLI: it must populate the documentation field + // (so `--help` can mention it) but must NOT populate the + // client-side default field — sending it on the wire when the + // caller omits the flag would change the API contract. Numbers + // keep their JSON type so the help-text suffix renders `25` not + // `"25"`. + let yaml = fern_default_yaml( + " - name: limit\n in: query\n schema:\n type: integer\n default: 100", + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let limit = doc.resources["users"].methods["list"] + .parameters + .get("limit") + .unwrap(); + assert!( + limit.default_value.is_none(), + "schema `default:` must not produce a client-side default" + ); + assert_eq!( + limit.documentation_default_value, + Some(serde_json::Value::Number(100.into())), + "schema `default: 100` should round-trip as a JSON number on the documentation field" + ); + } + + #[test] + fn test_x_fern_default_alone_lowers_as_client_default() { + // `x-fern-default` with no standard `default:` is plumbed into + // the client-side `default_value` field. Covers string, boolean, + // and integer scalar forms — the documentation field stays + // `None` because there is no schema `default:` to surface. + let yaml = fern_default_yaml( + r#" - name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + - name: enabled + in: query + x-fern-default: true + schema: + type: boolean + - name: pageSize + in: query + x-fern-default: 50 + schema: + type: integer"#, + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let params = &doc.resources["users"].methods["list"].parameters; + assert_eq!( + params["region"].default_value, + Some(serde_json::Value::String("us-east-1".to_string())) + ); + assert!(params["region"].documentation_default_value.is_none()); + assert_eq!( + params["enabled"].default_value, + Some(serde_json::Value::Bool(true)) + ); + assert!(params["enabled"].documentation_default_value.is_none()); + assert_eq!( + params["pageSize"].default_value, + Some(serde_json::Value::Number(50.into())) + ); + assert!(params["pageSize"].documentation_default_value.is_none()); + } + + #[test] + fn test_x_fern_default_supersedes_schema_default_for_help_too() { + // When both are present we want the client-side default field + // populated AND the documentation field cleared, so `--help` + // doesn't render two conflicting `[default: ...]` lines. The + // user-visible default is what the CLI will actually do (send + // `50`); the underlying server default is intentionally hidden + // because the API author opted into overriding it. + let yaml = fern_default_yaml( + r#" - name: limit + in: query + x-fern-default: 50 + schema: + type: integer + default: 100"#, + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let limit = doc.resources["users"].methods["list"] + .parameters + .get("limit") + .unwrap(); + assert_eq!( + limit.default_value, + Some(serde_json::Value::Number(50.into())), + "x-fern-default must drive the client-side default" + ); + assert!( + limit.documentation_default_value.is_none(), + "schema.default should not also be surfaced when x-fern-default is set" + ); + } + + #[test] + fn test_x_fern_default_at_ref_site_wins_over_resolved_component() { + // Ref-site precedence: when `x-fern-default` is placed alongside + // a `$ref`, it wins over the value on the resolved component + // parameter. Mirrors fern's `getExtension(parameter, FERN_DEFAULT) + // ?? getExtension(resolvedParameter, FERN_DEFAULT)`. The schema + // `default:` (a doc hint) is also suppressed because the + // client-side default takes over the `--help` slot. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + x-fern-default: "eu-west-1" + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert_eq!( + region.default_value, + Some(serde_json::Value::String("eu-west-1".to_string())), + "ref-site x-fern-default must win over both component-level x-fern-default and schema.default" + ); + assert!( + region.documentation_default_value.is_none(), + "schema.default should be suppressed when a client-side default exists" + ); + } + + #[test] + fn test_x_fern_default_from_resolved_component_when_no_ref_site_override() { + // Fallback half of the precedence: with no ref-site + // `x-fern-default`, the value on the resolved component + // parameter populates the client-side default, and the schema + // `default:` is still suppressed because the client-side slot + // is taken. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + x-fern-default: "us-east-1" + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert_eq!( + region.default_value, + Some(serde_json::Value::String("us-east-1".to_string())) + ); + assert!(region.documentation_default_value.is_none()); + } + + #[test] + fn test_schema_default_via_ref_lowers_as_documentation_only() { + // Even when the parameter is reached via `$ref`, a schema-level + // `default:` with no `x-fern-default` anywhere must NOT become a + // client-side default. It populates the documentation field so + // `--help` can surface `[API default: us-west-2]` without + // forcing the CLI to send the value on the wire. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + get: + operationId: users-list + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - $ref: '#/components/parameters/Region' + responses: + '200': + description: OK +components: + parameters: + Region: + name: region + in: query + schema: + type: string + default: "us-west-2" +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let region = doc.resources["users"].methods["list"] + .parameters + .get("region") + .unwrap(); + assert!( + region.default_value.is_none(), + "schema.default reached via $ref must stay doc-only" + ); + assert_eq!( + region.documentation_default_value, + Some(serde_json::Value::String("us-west-2".to_string())) + ); + } + + #[test] + fn test_inline_request_body_produces_per_field_body_params() { + // An inline object schema in `requestBody` should expose each top-level + // property as a body-located MethodParameter so that the command builder + // can render per-field flags. Read-only fields are skipped, and required + // fields keep their `required` bit so the executor can enforce them. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + post: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + count: + type: integer + tags: + type: array + items: + type: string + server_generated_id: + type: string + readOnly: true + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["things"].methods["create"]; + + let name = create + .parameters + .get("name") + .expect("name should be a body param"); + assert_eq!(name.location.as_deref(), Some("body")); + assert_eq!(name.param_type.as_deref(), Some("string")); + assert!(name.required, "name is in `required` and should be marked"); + + let count = create + .parameters + .get("count") + .expect("count should be a body param"); + assert_eq!(count.location.as_deref(), Some("body")); + assert_eq!(count.param_type.as_deref(), Some("integer")); + assert!(!count.required); + + // Array body properties become repeated flags (repeated: true, param_type: string). + let tags = create + .parameters + .get("tags") + .expect("tags should be a body param"); + assert_eq!(tags.location.as_deref(), Some("body")); + assert!(tags.repeated, "array body prop should have repeated: true"); + assert_eq!(tags.param_type.as_deref(), Some("string")); + + // Read-only fields don't get a flag — they're server-managed. + assert!( + !create.parameters.contains_key("server_generated_id"), + "readOnly properties should be skipped" + ); + } + + #[test] + fn test_body_depth_3_plus_not_flattened() { + // Mirrors MAX_INPUT_DEPTH in graphql/parser.rs: depths 0, 1, 2 are + // flattened into dot-notation flags; depth >= 3 is not. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + post: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + address: + type: object + properties: + city: + type: string + location: + type: object + properties: + street: + type: string + geo: + type: object + properties: + lat: + type: number + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["users"].methods["create"]; + + // Depth-0: top-level scalar. + assert!(create.parameters.contains_key("name"), "depth-0 'name' should be a flag"); + + // Depth-1: one level of nesting. + assert!(create.parameters.contains_key("address.city"), "depth-1 'address.city' should be a flag"); + + // Depth-2: two levels of nesting — now emitted (matches GraphQL behaviour). + assert!(create.parameters.contains_key("address.location.street"), "depth-2 'address.location.street' should be a flag"); + + // Depth-3: NOT emitted — beyond MAX_BODY_DEPTH. + assert!(!create.parameters.contains_key("address.location.geo.lat"), "depth-3 'address.location.geo.lat' must not be a flag"); + // address.location.geo surfaces as a plain object flag (depth limit hit, recursion returns empty). + assert!(create.parameters.contains_key("address.location.geo"), "depth-2 object at limit should surface as plain flag"); + } + + #[test] + fn test_ref_property_within_inline_schema_resolved() { + // A property within an inline body schema that uses $ref should be + // resolved from components/schemas rather than emitted as a typeless flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /orders: + post: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + note: + type: string + address: + $ref: '#/components/schemas/Address' + responses: + "201": + description: Created order +components: + schemas: + Address: + type: object + properties: + city: + type: string + zip: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["orders"].methods["create"]; + + // Top-level scalar — present as-is. + assert!(create.parameters.contains_key("note"), "'note' should be a flag"); + + // $ref to an object at depth 0 — resolved and flattened into dot-notation flags. + assert!(create.parameters.contains_key("address.city"), "'address.city' should be a flag after $ref resolution"); + assert!(create.parameters.contains_key("address.zip"), "'address.zip' should be a flag after $ref resolution"); + + // The $ref itself should appear as an object-typed shorthand flag (not a typeless flag). + let addr = create.parameters.get("address").expect("'address' should appear as an object-typed shorthand flag"); + assert_eq!(addr.param_type.as_deref(), Some("object"), "'address' must have param_type 'object', not be typeless"); + } + + #[test] + fn test_inline_object_body_param_emits_parent_object_flag() { + // For an inline object property, flatten_body_params_prefix should emit BOTH + // the parent key (param_type: "object", required: false) AND the dot-notation + // sub-flags (e.g. "name.first", "name.last"). + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /users: + post: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: object + description: Full name of the user + properties: + first: + type: string + last: + type: string + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["users"].methods["create"]; + + // Sub-flags must exist. + assert!(create.parameters.contains_key("name.first"), "name.first sub-flag must be present"); + assert!(create.parameters.contains_key("name.last"), "name.last sub-flag must be present"); + + // Parent object-level flag must ALSO exist. + let parent = create.parameters.get("name") + .expect("parent 'name' object flag must be present"); + assert_eq!(parent.param_type.as_deref(), Some("object"), "parent flag must have param_type 'object'"); + assert_eq!(parent.location.as_deref(), Some("body"), "parent flag must have location 'body'"); + // required is always false at CLI level for object shorthand flags. + assert!(!parent.required, "parent object flag must be required: false regardless of schema required"); + assert_eq!(parent.description.as_deref(), Some("Full name of the user"), "parent flag should carry description"); + } + + #[test] + fn test_ref_object_body_param_emits_parent_object_flag() { + // For a $ref that resolves to an object, flatten_body_params_prefix should emit + // BOTH the parent key (param_type: "object", required: false) AND the dot-notation + // sub-flags. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /orders: + post: + x-fern-sdk-group-name: ["orders"] + x-fern-sdk-method-name: create + requestBody: + content: + application/json: + schema: + type: object + properties: + address: + $ref: '#/components/schemas/Address' + responses: + "201": + description: Created order +components: + schemas: + Address: + type: object + description: Shipping address + properties: + city: + type: string + zip: + type: string +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["orders"].methods["create"]; + + // Sub-flags must still exist. + assert!(create.parameters.contains_key("address.city"), "'address.city' must be present"); + assert!(create.parameters.contains_key("address.zip"), "'address.zip' must be present"); + + // Parent object-level flag must ALSO exist now. + let parent = create.parameters.get("address") + .expect("parent 'address' object flag must be present for $ref branch"); + assert_eq!(parent.param_type.as_deref(), Some("object"), "parent flag must have param_type 'object'"); + assert_eq!(parent.location.as_deref(), Some("body"), "parent flag must have location 'body'"); + assert!(!parent.required, "parent object flag must be required: false"); + // $ref properties typically carry no inline description; the parent + // flag must fall back to the resolved schema's description so --help + // is not blank. + assert_eq!( + parent.description.as_deref(), + Some("Shipping address"), + "parent $ref object flag should fall back to the resolved schema's description" + ); + } + + #[test] + fn test_inline_body_does_not_clobber_query_params_with_same_name() { + // If a body schema property collides with an existing query/path/header + // parameter, the spec's `parameters` array wins — body-flag generation + // shouldn't silently turn a query param into a body param. + let yaml = r#" +openapi: "3.0.0" +info: + title: API + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + post: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: create + parameters: + - name: name + in: query + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + responses: + "201": + description: created +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["things"].methods["create"]; + + // `name` was claimed by the query param first — it stays a query param. + let name = &create.parameters["name"]; + assert_eq!(name.location.as_deref(), Some("query")); + + // `description` doesn't collide, lands in the body normally. + let description = &create.parameters["description"]; + assert_eq!(description.location.as_deref(), Some("body")); + } + + #[test] + fn test_per_operation_server_override() { + let yaml = r#" +openapi: "3.0.0" +info: + title: "API" + version: "1.0" +servers: + - url: "https://api.example.com" +paths: + /upload: + post: + servers: + - url: "https://upload.example.com" + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + responses: + "200": + description: ok + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + // Upload operation has its own server — should use it + let upload = doc.resources["uploads"].methods["create"].clone(); + assert_eq!(upload.root_url, "https://upload.example.com"); + // Users operation has no server override — falls back to spec-level + let users = doc.resources["users"].methods["list"].clone(); + assert_eq!(users.root_url, "https://api.example.com"); + } + + // ------------------------------------------------------------------ + // x-fern-idempotent + x-fern-idempotency-headers (FER-9864 P1). + // ------------------------------------------------------------------ + + /// Spec-root `x-fern-idempotency-headers` lowers to + /// `RestDescription.idempotency_headers` with the same shape, and an + /// operation marked `x-fern-idempotent: true` carries that flag + /// through to `RestMethod.idempotent`. + #[test] + fn test_idempotency_headers_parsed_from_spec_root() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + env: API_IDEMPOTENCY_KEY + - header: X-Trace-Id +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.idempotency_headers.len(), 2, "both header entries parsed"); + assert_eq!(doc.idempotency_headers[0].header, "Idempotency-Key"); + assert_eq!(doc.idempotency_headers[0].name.as_deref(), Some("idempotency_key")); + assert_eq!(doc.idempotency_headers[0].env.as_deref(), Some("API_IDEMPOTENCY_KEY")); + assert_eq!(doc.idempotency_headers[1].header, "X-Trace-Id"); + assert!(doc.idempotency_headers[1].name.is_none()); + assert!(doc.idempotency_headers[1].env.is_none()); + } + + /// `x-fern-idempotent: true` toggles `RestMethod.idempotent` and + /// synthesizes one header `MethodParameter` per spec-root entry. A + /// sibling operation without the extension is unaffected — its + /// parameter map contains no idempotency-header entries, which is + /// what guarantees the flags are not surfaced and the header is not + /// sent on non-idempotent ops. + #[test] + fn test_idempotent_op_surfaces_header_param_non_idempotent_does_not() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + name: idempotency_key + env: API_IDEMPOTENCY_KEY +paths: + /payments: + get: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: list + operationId: payments_list + responses: + "200": + description: ok + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let payments = doc.resources.get("payments").expect("payments group"); + + // Idempotent op + let create = payments.methods.get("create").expect("create method"); + assert!(create.idempotent, "create is x-fern-idempotent: true"); + let idem_param = create + .parameters + .get("Idempotency-Key") + .expect("synthetic idempotency header parameter exists"); + assert_eq!(idem_param.location.as_deref(), Some("header")); + assert_eq!(idem_param.env_var.as_deref(), Some("API_IDEMPOTENCY_KEY")); + + // Non-idempotent sibling + let list = payments.methods.get("list").expect("list method"); + assert!(!list.idempotent, "list is not idempotent"); + assert!( + !list.parameters.contains_key("Idempotency-Key"), + "non-idempotent op must not surface idempotency-header params", + ); + } + + /// An operation marked idempotent but with no spec-root header + /// definitions still flips `idempotent = true`; no synthetic + /// parameters are added because there are no headers to inject. + #[test] + fn test_idempotent_op_without_spec_root_headers_has_no_synthetic_params() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.idempotency_headers.is_empty()); + let create = &doc.resources["payments"].methods["create"]; + assert!(create.idempotent); + assert!( + create.parameters.is_empty(), + "no synthetic params without spec-root header definitions", + ); + } + + /// When the `IdempotencyHeader` entry sets `name`, the synthetic + /// `MethodParameter` carries a `flag_name_override` derived from + /// `to_kebab_flag(name)`. The HashMap key remains the wire header + /// name (so the executor still sends the correct HTTP header). + /// This is the case the upstream Fern OpenAPI importer's SDK + /// parameter naming covers — a header like `X-Trace-Id` with + /// `name: trace_id` materializes as `--trace-id` on the CLI, not + /// `--x-trace-id`. + #[test] + fn test_idempotent_op_uses_name_for_flag_derivation() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: X-Trace-Id + name: trace_id + - header: Idempotency-Key +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["payments"].methods["create"]; + + // X-Trace-Id with `name: trace_id` → wire key stays + // `X-Trace-Id`, but the flag becomes `--trace-id`. + let trace = create.parameters.get("X-Trace-Id").unwrap(); + assert_eq!(trace.flag_name_override.as_deref(), Some("trace-id")); + assert_eq!(trace.location.as_deref(), Some("header")); + + // No `name` → no override; flag derives from the header name + // via the existing `to_kebab_flag` path in `commands.rs`. + let idem = create.parameters.get("Idempotency-Key").unwrap(); + assert!(idem.flag_name_override.is_none()); + } + + /// Spec-declared parameters always win over a synthetic injection + /// with the same key — a per-operation `Idempotency-Key` declaration + /// keeps its description, schema, and any other customizations the + /// author put on it. + #[test] + fn test_spec_declared_param_wins_over_injection() { + let yaml = r#" +openapi: 3.0.2 +info: + title: Idempotency Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-idempotency-headers: + - header: Idempotency-Key + env: API_IDEMPOTENCY_KEY +paths: + /payments: + post: + x-fern-sdk-group-name: [payments] + x-fern-sdk-method-name: create + operationId: payments_create + x-fern-idempotent: true + parameters: + - name: Idempotency-Key + in: header + description: Custom description from author. + schema: + type: string + responses: + "201": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let create = &doc.resources["payments"].methods["create"]; + let p = create + .parameters + .get("Idempotency-Key") + .expect("declared param present"); + assert_eq!(p.description.as_deref(), Some("Custom description from author.")); + assert!( + p.env_var.is_none(), + "spec-declared param does not pick up env_var from the spec-root extension", + ); + } + + // ------------------------------------------------------------------ + // x-fern-global-headers (FER-9864 P2). + // ------------------------------------------------------------------ + + /// Absent extension → empty `global_headers` (the default-empty + /// `Vec` codepath). Pins the wire-compat baseline so a spec that + /// does not opt in is not changed. + #[test] + fn test_global_headers_absent_yields_empty_vec() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.global_headers.is_empty()); + } + + /// Full entry round-trips every field through the parser into + /// `RestDescription.global_headers`. Mirrors the upstream Fern + /// importer shape from `getGlobalHeaders.ts`. + #[test] + fn test_global_headers_full_entry_round_trips() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-API-Version + name: apiVersion + optional: false + env: API_VERSION + default: "2024-01-01" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.global_headers.len(), 1); + let h = &doc.global_headers[0]; + assert_eq!(h.header, "X-API-Version"); + assert_eq!(h.name.as_deref(), Some("apiVersion")); + assert!(!h.optional); + assert_eq!(h.env.as_deref(), Some("API_VERSION")); + assert_eq!(h.default.as_deref(), Some("2024-01-01")); + } + + /// Optional fields absent → defaults applied: `name` and `env` and + /// `default` are `None`, `optional` falls back to `false` (matching + /// upstream Fern's `?? false` default). + #[test] + fn test_global_headers_minimal_entry_uses_defaults() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Trace-Id +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let h = &doc.global_headers[0]; + assert_eq!(h.header, "X-Trace-Id"); + assert!(h.name.is_none()); + assert!(!h.optional, "optional defaults to false (i.e. required)"); + assert!(h.env.is_none()); + assert!(h.default.is_none()); + } + + /// `optional: true` lowers to `GlobalHeader.optional = true`. + /// Surfaces the required/optional toggle that the CLI registration + /// path consumes to decide whether to error on a missing value. + #[test] + fn test_global_headers_optional_true_lowers_to_optional() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Optional + optional: true +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.global_headers[0].optional); + } + + /// `default` accepts string / bool / number — they all lower to a + /// string for the outgoing HTTP header. Anything else (null / + /// sequence / mapping) drops to `None` rather than crashing. + #[test] + fn test_global_headers_default_accepts_scalar_types() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-String + default: "literal" + - header: X-Bool + default: true + - header: X-Number + default: 42 + - header: X-Null + default: null +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let by_header = |name: &str| -> &crate::openapi::discovery::GlobalHeader { + doc.global_headers + .iter() + .find(|h| h.header == name) + .expect("header parsed") + }; + assert_eq!(by_header("X-String").default.as_deref(), Some("literal")); + assert_eq!(by_header("X-Bool").default.as_deref(), Some("true")); + assert_eq!(by_header("X-Number").default.as_deref(), Some("42")); + assert!( + by_header("X-Null").default.is_none(), + "`null` is not a usable HTTP header value, so it drops to None" + ); + } + + /// `x-fern-default` takes precedence over `default` when both are + /// present, mirroring the upstream Fern importer where the + /// Fern-namespaced field is the authoritative source for header + /// defaults. + #[test] + fn test_global_headers_x_fern_default_overrides_default() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: X-Stage + default: "production" + x-fern-default: "development" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.global_headers[0].default.as_deref(), + Some("development"), + "x-fern-default wins over default" + ); + } + + /// Multiple entries preserve declaration order. The registration + /// pass in `app.rs` later iterates this Vec to register flags, and + /// help-text ordering follows source order — pin that here so the + /// surface is stable across refactors. + #[test] + fn test_global_headers_preserves_declaration_order() { + let yaml = r#" +openapi: 3.0.2 +info: + title: T + version: "1.0" +servers: + - url: https://api.example.com +x-fern-global-headers: + - header: First + - header: Second + - header: Third +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let headers: Vec<&str> = + doc.global_headers.iter().map(|h| h.header.as_str()).collect(); + assert_eq!(headers, vec!["First", "Second", "Third"]); + } + + // ------------------------------------------------------------------ + // x-fern-groups (FER-9864 P3). + // + // Document-root extension that decorates `x-fern-sdk-group-name` + // groups with `summary` / `description` metadata for the help + // surface. Shape mirrors the upstream Fern OpenAPI importer's + // `XFernGroupsSchema` zod schema and matching `SdkGroupInfo` IR + // type: + // fern-api/fern packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/extensions/getFernGroups.ts:8-14 + // fern-api/fern packages/cli/api-importers/openapi/openapi-ir/fern/definition/finalIr.yml:51-54 + // ------------------------------------------------------------------ + + const X_FERN_GROUPS_SPEC_SKELETON: &str = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + + /// Baseline: with no `x-fern-groups` extension on the document + /// root, `RestDescription::groups` is the empty map. This is the + /// "feature opted out" path — every consumer that calls + /// `doc.groups.get(...)` falls back to the legacy + /// `Operations on ''` rendering. + #[test] + fn test_x_fern_groups_absent_yields_empty_map() { + let doc = load_openapi_spec(X_FERN_GROUPS_SPEC_SKELETON, "test").unwrap(); + assert!(doc.groups.is_empty()); + } + + /// Single-group case: both `summary` and `description` flow + /// through to `SdkGroupInfo` verbatim. Verifies the kebab-cased + /// lookup key matches the resource-tree key the command builder + /// uses. + #[test] + fn test_x_fern_groups_single_group_round_trips() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + description: Long-form prose explaining the things group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert_eq!(info.summary.as_deref(), Some("Things Operations")); + assert_eq!( + info.description.as_deref(), + Some("Long-form prose explaining the things group.") + ); + } + + /// Multiple groups parse independently. Order is irrelevant for + /// the HashMap lookup, so the test asserts on per-key shape rather + /// than iteration order. + #[test] + fn test_x_fern_groups_multiple_groups_parse_independently() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + widgets: + summary: Widgets Operations + description: A second group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok + /widgets: + get: + x-fern-sdk-group-name: [widgets] + x-fern-sdk-method-name: list + operationId: widgets_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!(doc.groups.len(), 2); + assert_eq!( + doc.groups["things"].summary.as_deref(), + Some("Things Operations"), + ); + assert!(doc.groups["things"].description.is_none()); + assert_eq!( + doc.groups["widgets"].summary.as_deref(), + Some("Widgets Operations"), + ); + assert_eq!( + doc.groups["widgets"].description.as_deref(), + Some("A second group."), + ); + } + + /// Summary-only entry: `description` stays `None` so the command + /// builder falls back to the `about()` text when rendering + /// `--long-help`. + #[test] + fn test_x_fern_groups_summary_only_keeps_description_none() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert_eq!(info.summary.as_deref(), Some("Things Operations")); + assert!(info.description.is_none()); + } + + /// Description-only entry: `summary` stays `None`. The command + /// builder then keeps the legacy `Operations on ''` about + /// line while still surfacing the description via + /// `long_about()`. + #[test] + fn test_x_fern_groups_description_only_keeps_summary_none() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + description: Long-form prose about the group. +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + let info = doc.groups.get("things").expect("things entry present"); + assert!(info.summary.is_none()); + assert_eq!( + info.description.as_deref(), + Some("Long-form prose about the group."), + ); + } + + /// Group keys are kebab-cased so they line up with the resource + /// keys the command builder produces from `x-fern-sdk-group-name` + /// (which itself runs through `camel_to_kebab`). A `myGroup` entry + /// surfaces as `my-group`; the original casing is intentionally + /// not preserved. + #[test] + fn test_x_fern_groups_keys_are_kebab_cased() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + myGroup: + summary: Pretty Label +paths: + /things: + get: + x-fern-sdk-group-name: [myGroup] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert!(doc.groups.contains_key("my-group")); + assert!(!doc.groups.contains_key("myGroup")); + assert_eq!( + doc.groups["my-group"].summary.as_deref(), + Some("Pretty Label"), + ); + } + + /// Unrelated extra fields inside a group entry are ignored + /// rather than rejected. Fern's `getFernGroups.ts` schema is a + /// `z.object({ summary, description })` (no `.strict()`), so the + /// importer also tolerates extras — we mirror that to stay + /// forward-compatible with the documented `groups:` nesting + /// field on the wire (which the cli-sdk does not consume). + #[test] + fn test_x_fern_groups_tolerates_unknown_fields() { + let yaml = r#" +openapi: 3.0.2 +info: + title: t + version: "1" +servers: + - url: https://api.example.com +x-fern-groups: + things: + summary: Things Operations + groups: [other] +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + operationId: things_list + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "test").unwrap(); + assert_eq!( + doc.groups["things"].summary.as_deref(), + Some("Things Operations"), + ); + } + + // ------------------------------------------------------------------ + // Security scheme parsing + per-operation security inheritance. + // ------------------------------------------------------------------ + + fn first_method<'a>(doc: &'a RestDescription, group: &str, method: &str) -> &'a RestMethod { + doc.resources + .get(group) + .unwrap_or_else(|| panic!("resource '{group}' missing")) + .methods + .get(method) + .unwrap_or_else(|| panic!("method '{method}' on '{group}' missing")) + } + + #[test] + fn test_parses_http_bearer_security_scheme() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("bearerAuth"), + Some(&SecurityScheme::HttpBearer), + ); + } + + #[test] + fn test_parses_http_basic_security_scheme() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + basicAuth: + type: http + scheme: basic +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("basicAuth"), + Some(&SecurityScheme::HttpBasic), + ); + } + + #[test] + fn test_parses_apikey_header_and_query() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + headerKey: + type: apiKey + in: header + name: X-Api-Key + queryKey: + type: apiKey + in: query + name: api_key +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("headerKey"), + Some(&SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }), + ); + assert_eq!( + doc.security_schemes.get("queryKey"), + Some(&SecurityScheme::ApiKeyQuery { + name: "api_key".to_string(), + }), + ); + } + + #[test] + fn test_parses_oauth2_security_scheme_as_oauth2() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + oauthScheme: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://x.com/token + scopes: + read: read scope +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!( + doc.security_schemes.get("oauthScheme"), + Some(&SecurityScheme::OAuth2), + ); + } + + #[test] + fn test_unknown_security_type_falls_through_to_other() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + weird: + type: mutualTLS +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + match doc.security_schemes.get("weird") { + Some(SecurityScheme::Other(s)) => assert_eq!(s, "mutualtls"), + other => panic!("unexpected scheme: {other:?}"), + } + } + + #[test] + fn test_operation_inherits_spec_level_security() { + // Top-level `security: [{bearerAuth: []}]` is inherited by an + // operation that doesn't declare its own. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let reqs = m + .security_requirements + .as_ref() + .expect("inherited requirements present"); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("bearerAuth")); + } + + #[test] + fn test_operation_security_overrides_spec_default() { + // Operation declares its own `security` — that wins over the spec + // default, even if it picks a different scheme. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } + apiKey: { type: apiKey, in: header, name: X-Api-Key } +paths: + /admin: + get: + x-fern-sdk-group-name: ["admin"] + x-fern-sdk-method-name: ping + security: + - apiKey: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "admin", "ping"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("apiKey")); + assert!(!reqs[0].contains_key("bearerAuth")); + } + + #[test] + fn test_explicit_empty_operation_security_means_anonymous() { + // `security: []` on an operation is meaningful — it explicitly opts + // out of the spec-level default, marking the endpoint anonymous. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: + - bearerAuth: [] +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer } +paths: + /public: + get: + x-fern-sdk-group-name: ["public"] + x-fern-sdk-method-name: ping + security: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "public", "ping"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert!( + reqs.is_empty(), + "explicit empty array should produce Some(vec![]), got {reqs:?}", + ); + } + + #[test] + fn test_no_security_anywhere_leaves_requirements_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + assert!(m.security_requirements.is_none()); + } + + #[test] + fn test_spec_level_empty_security_inherited_as_anonymous() { + // `security: []` at the spec root means every operation is + // anonymous by default unless it declares its own. Inheritance + // should propagate the explicit empty vec through. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +security: [] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert!( + reqs.is_empty(), + "spec-level explicit anonymous should propagate, got {reqs:?}", + ); + } + + #[test] + fn test_security_scheme_type_and_scheme_are_case_insensitive() { + // OpenAPI doesn't formally constrain casing on `type` / `scheme`; + // real-world specs vary. Match generously. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + a: + type: HTTP + scheme: Bearer + b: + type: ApiKey + in: HEADER + name: X-Api-Key +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.security_schemes.get("a"), Some(&SecurityScheme::HttpBearer)); + assert_eq!( + doc.security_schemes.get("b"), + Some(&SecurityScheme::ApiKeyHeader { + name: "X-Api-Key".to_string(), + }), + ); + } + + #[test] + fn test_operation_can_reference_undeclared_scheme() { + // An operation referencing a scheme not in components.securitySchemes + // is preserved verbatim — Phase 3's RoutingAuthProvider will simply + // have no binding for it and fall through. Some real-world specs + // reference externally-configured schemes this way. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /thing: + get: + x-fern-sdk-group-name: ["thing"] + x-fern-sdk-method-name: get + security: + - externalScheme: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "thing", "get"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 1); + assert!(reqs[0].contains_key("externalScheme")); + // No declaration in components.securitySchemes — that's fine. + assert!(doc.security_schemes.is_empty()); + } + + #[test] + fn test_or_of_ands_security_requirements() { + // The classic `[{a: []}, {b: [], c: []}]` shape: satisfy a alone, OR + // (b AND c). Verifies we preserve the structure verbatim. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +components: + securitySchemes: + a: { type: http, scheme: bearer } + b: { type: apiKey, in: header, name: X-B } + c: { type: apiKey, in: header, name: X-C } +paths: + /complex: + get: + x-fern-sdk-group-name: ["complex"] + x-fern-sdk-method-name: get + security: + - a: [] + - b: [] + c: [] + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "complex", "get"); + let reqs = m.security_requirements.as_ref().unwrap(); + assert_eq!(reqs.len(), 2); + // First alternative: just `a`. + assert!(reqs[0].contains_key("a")); + assert_eq!(reqs[0].len(), 1); + // Second alternative: `b` AND `c`. + assert!(reqs[1].contains_key("b")); + assert!(reqs[1].contains_key("c")); + assert_eq!(reqs[1].len(), 2); + } + + // ----------------------------------------------------------------------- + // deep_merge_yaml tests — matches Fern CLI mergeWithOverrides behavior + // ----------------------------------------------------------------------- + + // -- Scalar / map basics ------------------------------------------------ + + #[test] + fn test_deep_merge_scalars_override_wins() { + let base: serde_yaml::Value = serde_yaml::from_str("title: Original").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("title: Overridden").unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["title"], serde_yaml::Value::String("Overridden".into())); + } + + #[test] + fn test_deep_merge_adds_new_keys() { + let base: serde_yaml::Value = serde_yaml::from_str("a: 1").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("b: 2").unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["a"], serde_yaml::Value::Number(1.into())); + assert_eq!(merged["b"], serde_yaml::Value::Number(2.into())); + } + + /// Fern CLI test: "should handle nested object merging" + #[test] + fn test_deep_merge_nested_object_merging() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + config: + settings: + theme: light + notifications: true + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + config: + settings: + theme: dark + sound: false + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["config"]["settings"]["theme"], serde_yaml::Value::String("dark".into())); + assert_eq!(merged["config"]["settings"]["notifications"], serde_yaml::Value::Bool(true)); + assert_eq!(merged["config"]["settings"]["sound"], serde_yaml::Value::Bool(false)); + } + + /// Fern CLI test: "deep-merges nested objects rather than replacing them" + #[test] + fn test_deep_merge_nested_sibling_keys() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + foo: + bar: + existingKey: original + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + foo: + bar: + newKey: added + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!( + merged["foo"]["bar"]["existingKey"], + serde_yaml::Value::String("original".into()) + ); + assert_eq!( + merged["foo"]["bar"]["newKey"], + serde_yaml::Value::String("added".into()) + ); + } + + #[test] + fn test_deep_merge_nested_openapi_paths() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + paths: + /users: + get: + summary: List users + operationId: listUsers + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + paths: + /users: + get: + x-fern-sdk-group-name: [users] + x-fern-sdk-method-name: list + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!( + merged["paths"]["/users"]["get"]["summary"], + serde_yaml::Value::String("List users".into()) + ); + assert_eq!( + merged["paths"]["/users"]["get"]["operationId"], + serde_yaml::Value::String("listUsers".into()) + ); + assert_eq!( + merged["paths"]["/users"]["get"]["x-fern-sdk-method-name"], + serde_yaml::Value::String("list".into()) + ); + } + + // -- Null deletion (omitDeepBy(isNull)) --------------------------------- + + #[test] + fn test_deep_merge_null_deletes_key() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + info: + title: API + description: A description + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + info: + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["info"]["title"], serde_yaml::Value::String("API".into())); + let info = merged["info"].as_mapping().unwrap(); + assert!(!info.contains_key("description"), "null should delete the key"); + } + + /// Fern CLI test: "removes null values from merged result" + #[test] + fn test_deep_merge_null_removes_from_merged_result() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + title: Title + description: A description + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + assert_eq!(merged["title"], serde_yaml::Value::String("Title".into())); + assert!(!merged.as_mapping().unwrap().contains_key("description")); + } + + /// Nulls inside non-allowlisted keys are still removed. + #[test] + fn test_deep_merge_removes_pre_existing_nulls_outside_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + type: object + properties: + name: + type: string + description: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let name = merged["properties"]["name"].as_mapping().unwrap(); + assert!(name.contains_key("type")); + assert!(!name.contains_key("description"), "null outside examples should be removed"); + } + + /// Fern CLI parity: nulls inside `examples` keys are preserved + /// (allowNullKeys = ["examples"]). + #[test] + fn test_deep_merge_preserves_nulls_inside_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + type: object + properties: + name: + type: string + examples: + example1: John + example2: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let examples = merged["properties"]["name"]["examples"].as_mapping().unwrap(); + assert!(examples.contains_key("example1")); + assert!(examples.contains_key("example2"), "null inside examples should be preserved"); + assert!(examples.get("example2").unwrap().is_null()); + } + + /// Nulls deeply nested under an `examples` key are also preserved. + #[test] + fn test_deep_merge_preserves_nulls_deeply_nested_under_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + examples: + myExample: + value: + name: John + email: null + nested: + field: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let value = &merged["examples"]["myExample"]["value"]; + assert!(value["email"].is_null(), "null under examples descendant preserved"); + assert!(value["nested"]["field"].is_null(), "deeply nested null under examples preserved"); + } + + /// Nulls outside `examples` are removed even when siblings of examples. + #[test] + fn test_deep_merge_mixed_examples_and_non_examples_nulls() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + schema: + description: null + examples: + ex1: null + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let schema = merged["schema"].as_mapping().unwrap(); + assert!(!schema.contains_key("description"), "null outside examples removed"); + let examples = schema.get("examples").unwrap().as_mapping().unwrap(); + assert!(examples.contains_key("ex1"), "null inside examples preserved"); + } + + /// Null deletion should be recursive through deeply nested maps. + #[test] + fn test_deep_merge_null_deletes_deeply_nested() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + a: + b: + c: + keep: true + remove_me: value + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + a: + b: + c: + remove_me: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let c = merged["a"]["b"]["c"].as_mapping().unwrap(); + assert!(c.contains_key("keep")); + assert!(!c.contains_key("remove_me")); + } + + // -- Array of primitives: replaced wholesale (Fern parity) -------------- + + /// Fern CLI test: "should replace arrays of primitives" + #[test] + fn test_deep_merge_primitive_arrays_replaced_wholesale() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + tags: [tag1, tag2] + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + tags: [tag3, tag4] + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let tags = merged["tags"].as_sequence().unwrap(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0], serde_yaml::Value::String("tag3".into())); + assert_eq!(tags[1], serde_yaml::Value::String("tag4".into())); + } + + #[test] + fn test_deep_merge_primitive_array_shorter_override_replaces() { + let base: serde_yaml::Value = serde_yaml::from_str("tags: [a, b, c]").unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("tags: [x]").unwrap(); + let merged = deep_merge_yaml(base, overrides); + let tags = merged["tags"].as_sequence().unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0], serde_yaml::Value::String("x".into())); + } + + // -- Arrays of objects: merged element-by-element (Fern parity) --------- + + /// Fern CLI test: "should merge arrays of objects" + #[test] + fn test_deep_merge_object_arrays_merged_by_index() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - id: 1 + name: Item 1 + - id: 2 + name: Item 2 + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - id: 1 + description: Updated Item 1 + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let items = merged["items"].as_sequence().unwrap(); + // Element 0 merged: base {id:1, name: Item 1} + override {id:1, description: Updated Item 1} + assert_eq!(items[0]["id"], serde_yaml::Value::Number(1.into())); + assert_eq!(items[0]["name"], serde_yaml::Value::String("Item 1".into())); + assert_eq!(items[0]["description"], serde_yaml::Value::String("Updated Item 1".into())); + // Element 1 carried from base (override only has 1 element) + assert_eq!(items.len(), 2); + assert_eq!(items[1]["id"], serde_yaml::Value::Number(2.into())); + assert_eq!(items[1]["name"], serde_yaml::Value::String("Item 2".into())); + } + + /// Override array longer than base — extra elements appended. + #[test] + fn test_deep_merge_object_arrays_override_longer() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + servers: + - url: "https://a.com" + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + servers: + - url: "https://a-patched.com" + - url: "https://b.com" + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let servers = merged["servers"].as_sequence().unwrap(); + assert_eq!(servers.len(), 2); + assert_eq!(servers[0]["url"], serde_yaml::Value::String("https://a-patched.com".into())); + assert_eq!(servers[1]["url"], serde_yaml::Value::String("https://b.com".into())); + } + + /// OpenAPI parameters array (array of objects) should merge by index. + #[test] + fn test_deep_merge_parameters_array_merges_by_index() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + parameters: + - name: limit + in: query + required: false + - name: offset + in: query + required: false + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + parameters: + - description: Maximum number of results + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let params = merged["parameters"].as_sequence().unwrap(); + assert_eq!(params.len(), 2); + // First param: merged with override + assert_eq!(params[0]["name"], serde_yaml::Value::String("limit".into())); + assert_eq!(params[0]["description"], serde_yaml::Value::String("Maximum number of results".into())); + // Second param: untouched from base + assert_eq!(params[1]["name"], serde_yaml::Value::String("offset".into())); + } + + // -- Mixed arrays (primitives + objects): replaced wholesale ------------- + + #[test] + fn test_deep_merge_mixed_array_replaced_wholesale() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + mixed: + - name: obj + - just_a_string + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + mixed: + - replaced: true + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let mixed = merged["mixed"].as_sequence().unwrap(); + // Base had mixed types → override replaces wholesale + assert_eq!(mixed.len(), 1); + } + + // -- Enum arrays (primitives) in schemas -------------------------------- + + #[test] + fn test_deep_merge_enum_array_replaced() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + accountStatus: + type: string + enum: [active, suspended, deleted] + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + accountStatus: + enum: [active, suspended, deleted, inactive] + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let enums = merged["accountStatus"]["enum"].as_sequence().unwrap(); + assert_eq!(enums.len(), 4); + assert_eq!(enums[3], serde_yaml::Value::String("inactive".into())); + // type preserved from base + assert_eq!(merged["accountStatus"]["type"], serde_yaml::Value::String("string".into())); + } + + // -- Overrides-resolution fixture parity -------------------------------- + + /// Matches the Fern CLI overrides-resolution fixture: override adds a new + /// property (lastName) to an existing schema, preserving existing ones. + #[test] + fn test_deep_merge_override_adds_schema_property() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserUpdate: + type: object + properties: + name: + type: string + email: + type: string + nullable: true + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserUpdate: + type: object + properties: + name: + type: string + lastName: + type: string + email: + type: string + nullable: true + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let props = merged["components"]["schemas"]["UserUpdate"]["properties"] + .as_mapping().unwrap(); + assert!(props.contains_key("name")); + assert!(props.contains_key("lastName"), "new property from override"); + assert!(props.contains_key("email")); + } + + /// Override introduces an entirely new schema that doesn't exist in the base. + #[test] + fn test_deep_merge_override_adds_new_schema() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + User: + type: object + properties: + id: + type: string + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + components: + schemas: + UserStats: + type: object + properties: + totalLogins: + type: integer + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let schemas = merged["components"]["schemas"].as_mapping().unwrap(); + assert!(schemas.contains_key("User"), "base schema preserved"); + assert!(schemas.contains_key("UserStats"), "new schema from override"); + } + + // -- Sequential override application ------------------------------------ + + #[test] + fn test_deep_merge_multiple_overrides_applied_sequentially() { + let base: serde_yaml::Value = serde_yaml::from_str("a: 1\nb: 2\nc: 3").unwrap(); + let ovr1: serde_yaml::Value = serde_yaml::from_str("a: 10\nd: 4").unwrap(); + let ovr2: serde_yaml::Value = serde_yaml::from_str("a: 100\nb: null").unwrap(); + let merged = deep_merge_yaml(deep_merge_yaml(base, ovr1), ovr2); + assert_eq!(merged["a"], serde_yaml::Value::Number(100.into())); + assert!(!merged.as_mapping().unwrap().contains_key("b")); + assert_eq!(merged["c"], serde_yaml::Value::Number(3.into())); + assert_eq!(merged["d"], serde_yaml::Value::Number(4.into())); + } + + // -- Empty overrides is identity ---------------------------------------- + + #[test] + fn test_deep_merge_empty_override_is_identity() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + info: + title: API + version: "1.0" + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str("{}").unwrap(); + let merged = deep_merge_yaml(base.clone(), overrides); + // With the exception of pre-existing nulls being removed, result + // should match. This base has none, so it should be identical. + assert_eq!(merged["info"]["title"], base["info"]["title"]); + assert_eq!(merged["info"]["version"], base["info"]["version"]); + } + + // -- End-to-end: override adds Fern extensions, parser reflects them ---- + + #[test] + fn test_deep_merge_override_adds_fern_extensions_to_spec() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list +"#; + // Without overrides: method name from operationId + let doc_no_override = load_openapi_spec(base_yaml, "t").unwrap(); + let customers = &doc_no_override.resources["customers"]; + assert!(customers.methods.contains_key("get-customers")); + + // With overrides: method name from x-fern-sdk-method-name + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc_with_override = load_openapi_spec_from_value(merged, "t").unwrap(); + let customers = &doc_with_override.resources["customers"]; + assert!( + customers.methods.contains_key("list"), + "override should set method name to 'list', got keys: {:?}", + customers.methods.keys().collect::>() + ); + } + + /// Multi-operation override: adds fern extensions to multiple endpoints. + #[test] + fn test_deep_merge_multi_operation_fern_extensions() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /customers: + get: + tags: [Customers] + operationId: getCustomers + responses: { "200": { description: ok } } + post: + tags: [Customers] + operationId: createCustomer + responses: { "201": { description: created } } + /orders: + get: + tags: [Orders] + operationId: getOrders + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /customers: + get: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: list + post: + x-fern-sdk-group-name: [customers] + x-fern-sdk-method-name: create + /orders: + get: + x-fern-sdk-group-name: [orders] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc = load_openapi_spec_from_value(merged, "t").unwrap(); + let customers = &doc.resources["customers"]; + assert!(customers.methods.contains_key("list")); + assert!(customers.methods.contains_key("create")); + let orders = &doc.resources["orders"]; + assert!(orders.methods.contains_key("list")); + } + + /// Override re-groups an operation into a different resource. + #[test] + fn test_deep_merge_override_changes_group_name() { + let base_yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /admin/users: + get: + tags: [Admin] + operationId: adminListUsers + responses: { "200": { description: ok } } +"#; + let overrides_yaml = r#" +paths: + /admin/users: + get: + x-fern-sdk-group-name: [admin, users] + x-fern-sdk-method-name: list +"#; + let base_val: serde_yaml::Value = serde_yaml::from_str(base_yaml).unwrap(); + let ovr_val: serde_yaml::Value = serde_yaml::from_str(overrides_yaml).unwrap(); + let merged = deep_merge_yaml(base_val, ovr_val); + let doc = load_openapi_spec_from_value(merged, "t").unwrap(); + let admin = &doc.resources["admin"]; + let users = &admin.resources["users"]; + assert!( + users.methods.contains_key("list"), + "override should place method under admin.users" + ); + } + + // -- Null removal inside arrays of objects ------------------------------ + + #[test] + fn test_deep_merge_null_removed_inside_object_array() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - name: keep + remove: value + "#).unwrap(); + let overrides: serde_yaml::Value = serde_yaml::from_str(r#" + items: + - remove: null + "#).unwrap(); + let merged = deep_merge_yaml(base, overrides); + let item = &merged["items"].as_sequence().unwrap()[0]; + let map = item.as_mapping().unwrap(); + assert!(map.contains_key("name")); + assert!(!map.contains_key("remove"), "null inside object array element should be removed"); + } + + // -- Verification: from_str vs from_value round-trip -------------------- + + // -- Verification: allowNullKeys covers all Fern CLI keys --------------- + + #[test] + fn test_allow_null_keys_covers_all_fern_cli_keys() { + assert!(ALLOW_NULL_KEYS.contains(&"examples")); + assert!(ALLOW_NULL_KEYS.contains(&"example")); + assert!(ALLOW_NULL_KEYS.contains(&"x-fern-examples")); + assert!(ALLOW_NULL_KEYS.contains(&"x-code-samples")); + assert!(ALLOW_NULL_KEYS.contains(&"x-codeSamples")); + assert_eq!(ALLOW_NULL_KEYS.len(), 5, "should have exactly 5 keys matching Fern CLI"); + } + + #[test] + fn test_null_preserved_under_example_singular() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + schema: + example: null + description: null + "#).unwrap(); + let merged = deep_merge_yaml(base.clone(), serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let map = merged.as_mapping().unwrap(); + let schema = map.get("schema").unwrap().as_mapping().unwrap(); + assert!(schema.contains_key("example"), "'example' (singular) null should be preserved"); + assert!(!schema.contains_key("description"), "'description' null should be removed"); + } + + #[test] + fn test_null_preserved_under_x_fern_examples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + x-fern-examples: + - value: null + "#).unwrap(); + let merged = deep_merge_yaml(base, serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let seq = merged["x-fern-examples"].as_sequence().unwrap(); + let item = seq[0].as_mapping().unwrap(); + assert!(item.get("value").unwrap().is_null(), "null under x-fern-examples should be preserved"); + } + + #[test] + fn test_null_preserved_under_x_code_samples() { + let base: serde_yaml::Value = serde_yaml::from_str(r#" + x-code-samples: + - lang: python + source: null + "#).unwrap(); + let merged = deep_merge_yaml(base, serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + let item = &merged["x-code-samples"].as_sequence().unwrap()[0]; + assert!(item["source"].is_null(), "null under x-code-samples should be preserved"); + } + + // -- Verification: real overrides e2e ----------------------------------- + + // -- Verification: all_objects heuristic -------------------------------- + + #[test] + fn test_all_objects_empty_arrays_vacuous_truth() { + assert!(all_objects(&[]), "empty array should pass all_objects (vacuous truth)"); + let base: serde_yaml::Value = serde_yaml::from_str("items: []").unwrap(); + let ovr: serde_yaml::Value = serde_yaml::from_str("items: []").unwrap(); + let merged = deep_merge_yaml(base, ovr); + assert_eq!(merged["items"].as_sequence().unwrap().len(), 0, "two empty arrays merge to empty"); + } + + #[test] + fn test_all_objects_servers_array_is_all_objects() { + let yaml = r#" +servers: + - url: https://api.example.com + - url: https://api2.example.com +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let servers = val["servers"].as_sequence().unwrap(); + assert!(all_objects(servers), "servers array should be all objects → index-merge path"); + } + + #[test] + fn test_all_objects_tags_array_is_primitives() { + let yaml = r#" +tags: + - Customers + - Orders +"#; + let val: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let tags = val["tags"].as_sequence().unwrap(); + assert!(!all_objects(tags), "string array should NOT be all objects → replace path"); + } + + // --------------------------------------------------------------- + // `x-fern-pagination` resolution + // --------------------------------------------------------------- + + fn yaml(input: &str) -> serde_yaml::Value { + serde_yaml::from_str(input).expect("valid yaml in test fixture") + } + + #[test] + fn test_strip_pagination_prefix_request_and_response() { + assert_eq!(strip_pagination_prefix("$request.cursor"), "cursor"); + assert_eq!( + strip_pagination_prefix("$response.pagination.next_cursor"), + "pagination.next_cursor" + ); + // No prefix: returned verbatim. This matches the upstream importer, + // which is intentionally lenient about callers that already passed + // a dotted path. + assert_eq!(strip_pagination_prefix("plain"), "plain"); + } + + #[test] + fn test_resolve_pagination_cursor_form_strips_prefixes() { + let op = yaml( + r#" +cursor: $request.starting_after +next_cursor: $response.pagination.next +results: $response.data +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("cursor form should produce Some(...)"); + match cfg { + PaginationConfig::Cursor { + cursor, + next_cursor, + results, + } => { + assert_eq!(cursor, "starting_after"); + assert_eq!(next_cursor, "pagination.next"); + assert_eq!(results, "data"); + } + other => panic!("expected Cursor, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_offset_form_with_step_and_has_next_page() { + let op = yaml( + r#" +offset: $request.page +results: $response.users +step: $request.page_size +has-next-page: $response.meta.has_more +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listUsers") + .unwrap() + .expect("offset form should produce Some(...)"); + match cfg { + PaginationConfig::Offset { + offset, + results, + step, + has_next_page, + } => { + assert_eq!(offset, "page"); + assert_eq!(results, "users"); + assert_eq!(step.as_deref(), Some("page_size")); + assert_eq!(has_next_page.as_deref(), Some("meta.has_more")); + } + other => panic!("expected Offset, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_inherits_root_when_op_is_true() { + let root = yaml( + r#" +cursor: $request.cursor +next_cursor: $response.next_cursor +results: $response.items +"#, + ); + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .unwrap() + .expect("true should inherit root config"); + match cfg { + PaginationConfig::Cursor { + cursor, + next_cursor, + results, + } => { + assert_eq!(cursor, "cursor"); + assert_eq!(next_cursor, "next_cursor"); + assert_eq!(results, "items"); + } + other => panic!("expected Cursor, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_op_false_inherits_root_like_upstream() { + // Upstream `getFernPaginationExtension.ts` treats *any* boolean — + // including `false` — as "look up the root extension". Mirror that + // exactly so cli-sdk has parity with the rest of the Fern toolchain. + let root = yaml( + r#" +cursor: $request.cursor +next_cursor: $response.next_cursor +results: $response.items +"#, + ); + let op = serde_yaml::Value::Bool(false); + let cfg = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .unwrap() + .expect("false should still resolve via root (upstream parity)"); + assert!(matches!(cfg, PaginationConfig::Cursor { .. })); + } + + #[test] + fn test_resolve_pagination_missing_extension_returns_none() { + let cfg = resolve_pagination_extension(None, None, "listFoos").unwrap(); + assert!(cfg.is_none(), "absent extension → fall back to heuristic"); + } + + #[test] + fn test_resolve_pagination_op_true_without_root_returns_none() { + // Upstream returns `undefined` (no pagination) when the op asks to + // inherit but no root block exists. It does *not* raise. Mirror. + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos").unwrap(); + assert!( + cfg.is_none(), + "true without root → no pagination (upstream parity)" + ); + } + + #[test] + fn test_resolve_pagination_discrimination_order_matches_upstream() { + // Upstream's `convertPaginationExtension` discriminates by checking + // `cursor` first, then `next_uri`, then `next_path`, then `offset`. + // When multiple keys collide we must pick the cursor branch — the + // first one — to stay consistent with how user specs are + // interpreted by the rest of the Fern toolchain. + let op = yaml( + r#" +cursor: $request.cursor +offset: $request.page +results: $response.items +next_cursor: $response.next +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("should resolve to cursor variant"); + assert!( + matches!(cfg, PaginationConfig::Cursor { .. }), + "cursor should win when both `cursor` and `offset` are present" + ); + } + + #[test] + fn test_resolve_pagination_unknown_form_errors() { + // Just `results` — no discriminator. Upstream throws + // `Invalid pagination extension`; we surface a discovery error + // referencing every valid form so the user can debug. + let op = yaml("results: $response.items\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("unknown form should error"); + let msg = format!("{err}"); + assert!(msg.contains("cursor"), "got: {msg}"); + assert!(msg.contains("next_uri"), "got: {msg}"); + assert!(msg.contains("next_path"), "got: {msg}"); + assert!(msg.contains("offset"), "got: {msg}"); + assert!(msg.contains("custom"), "got: {msg}"); + } + + #[test] + fn test_resolve_pagination_non_object_form_errors() { + let op = yaml("- not\n- an\n- object\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("sequence should error"); + assert!( + format!("{err}").contains("expected an object"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_cursor_form_requires_all_fields() { + // `next_cursor` is missing. + let op = yaml( + r#" +cursor: $request.starting_after +results: $response.data +"#, + ); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("missing next_cursor should error"); + assert!( + format!("{err}").contains("next_cursor"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_uri_form() { + let op = yaml( + r#" +next_uri: $response.next +results: $response.items +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("uri form should resolve"); + match cfg { + PaginationConfig::Uri { next_uri, results } => { + assert_eq!(next_uri, "next"); + assert_eq!(results, "items"); + } + other => panic!("expected Uri, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_path_form() { + let op = yaml( + r#" +next_path: $response.links.next +results: $response.entries +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("path form should resolve"); + match cfg { + PaginationConfig::Path { next_path, results } => { + assert_eq!(next_path, "links.next"); + assert_eq!(results, "entries"); + } + other => panic!("expected Path, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_custom_form() { + let op = yaml( + r#" +type: custom +results: $response.items +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("custom form should resolve"); + match cfg { + PaginationConfig::Custom { results } => assert_eq!(results, "items"), + other => panic!("expected Custom, got {other:?}"), + } + } + + #[test] + fn test_resolve_pagination_custom_form_rejects_unknown_type() { + // `type: anythingElse` is not a valid discriminator, so we fall + // through to the "unknown form" error. + let op = yaml( + r#" +type: nonsense +results: $response.items +"#, + ); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("non-custom `type` should error"); + assert!( + format!("{err}").contains("`type: custom`"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_op_bool_with_root_bool_is_validation_error() { + // Mirrors upstream: when both per-op and root are booleans, raise. + let root = serde_yaml::Value::Bool(true); + let op = serde_yaml::Value::Bool(true); + let err = resolve_pagination_extension(Some(&op), Some(&root), "listFoos") + .expect_err("root-also-bool should error"); + assert!( + format!("{err}").contains("spec-root"), + "got: {err}" + ); + } + + #[test] + fn test_resolve_pagination_uri_form_requires_both_fields() { + // `results` is missing. + let op = yaml("next_uri: $response.next\n"); + let err = resolve_pagination_extension(Some(&op), None, "listFoos") + .expect_err("missing results should error"); + assert!(format!("{err}").contains("results"), "got: {err}"); + } + + #[test] + fn test_resolve_pagination_offset_form_with_optional_fields() { + let op = yaml( + r#" +offset: $request.page +results: $response.items +step: $request.page_size +has-next-page: $response.has_more +"#, + ); + let cfg = resolve_pagination_extension(Some(&op), None, "listFoos") + .unwrap() + .expect("offset form should resolve"); + match cfg { + PaginationConfig::Offset { + offset, + results, + step, + has_next_page, + } => { + assert_eq!(offset, "page"); + assert_eq!(results, "items"); + assert_eq!(step.as_deref(), Some("page_size")); + assert_eq!(has_next_page.as_deref(), Some("has_more")); + } + other => panic!("expected Offset, got {other:?}"), + } + } + + // ------------------------------------------------------------------ + // x-fern-availability — operation level + // ------------------------------------------------------------------ + + /// Build a single-operation spec with the given `extra` YAML injected + /// inside the GET operation. Returns the parsed `RestMethod`. + fn parse_op_with_extra(extra: &str) -> RestDescription { + let yaml = format!( + r#" +openapi: "3.0.0" +info: {{ title: T, version: "1.0" }} +servers: [{{ url: "https://x.com" }}] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list +{extra} + responses: {{ "200": {{ description: ok }} }} +"# + ); + load_openapi_spec(&yaml, "t").unwrap() + } + + #[test] + fn test_operation_availability_beta() { + let doc = parse_op_with_extra(" x-fern-availability: beta"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Beta)); + } + + #[test] + fn test_operation_availability_pre_release() { + let doc = parse_op_with_extra(" x-fern-availability: pre-release"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::PreRelease)); + } + + #[test] + fn test_operation_availability_generally_available_canonical() { + let doc = parse_op_with_extra(" x-fern-availability: generally-available"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::GenerallyAvailable)); + } + + #[test] + fn test_operation_availability_alias_ga() { + let doc = parse_op_with_extra(" x-fern-availability: ga"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::GenerallyAvailable)); + } + + #[test] + fn test_operation_availability_alpha() { + let doc = parse_op_with_extra(" x-fern-availability: alpha"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Alpha)); + } + + #[test] + fn test_operation_availability_preview() { + let doc = parse_op_with_extra(" x-fern-availability: preview"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Preview)); + } + + #[test] + fn test_operation_availability_legacy() { + let doc = parse_op_with_extra(" x-fern-availability: legacy"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Legacy)); + } + + /// `stable` is NOT a valid Fern availability — the Fern OpenAPI + /// importer accepts only `ga` (and the canonical + /// `generally-available`). Make sure cli-sdk rejects `stable` for + /// parity, so it can't silently work in one tool and not the other. + #[test] + fn test_operation_availability_stable_is_rejected() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + x-fern-availability: stable + responses: { "200": { description: ok } } +"#; + let err = load_openapi_spec(yaml, "t") + .expect_err("`stable` must NOT be accepted — only `ga` and `generally-available` are"); + let msg = err.to_string(); + assert!( + msg.contains("unknown variant") || msg.contains("variant `stable`"), + "expected serde deser error mentioning the unknown variant `stable`, got: {msg}", + ); + } + + /// Locks in the canonical wire spelling for `pre-release` so the + /// kebab-case rename can't drift. The Fern OpenAPI IR importer + /// collapses `pre-release` into `Beta`; cli-sdk deliberately keeps + /// `PreRelease` distinct (see `Availability` enum docs). + #[test] + fn test_operation_availability_pre_release_wire_spelling() { + let doc = parse_op_with_extra(" x-fern-availability: pre-release"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::PreRelease), + "`pre-release` must deser to its own variant, not collapse to Beta", + ); + assert_eq!(m.availability.unwrap().as_str(), "pre-release"); + assert_eq!(m.availability.unwrap().badge(), Some("[PRE-RELEASE]")); + } + + #[test] + fn test_operation_availability_deprecated_value() { + let doc = parse_op_with_extra(" x-fern-availability: deprecated"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, Some(Availability::Deprecated)); + } + + #[test] + fn test_operation_availability_absent_defaults_to_none() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.availability, None, "no extension and no deprecated flag → no badge"); + } + + #[test] + fn test_operation_openapi_deprecated_true_falls_back_to_deprecated() { + let doc = parse_op_with_extra(" deprecated: true"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::Deprecated), + "OpenAPI standard `deprecated: true` should lower to Availability::Deprecated when x-fern-availability is absent", + ); + } + + #[test] + fn test_operation_x_fern_availability_overrides_openapi_deprecated() { + // Both set — x-fern-availability wins. + let doc = parse_op_with_extra( + " deprecated: true\n x-fern-availability: beta", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.availability, + Some(Availability::Beta), + "explicit x-fern-availability must override OpenAPI deprecated:true", + ); + } + + // ------------------------------------------------------------------ + // x-fern-availability — parameter level + // ------------------------------------------------------------------ + + #[test] + fn test_parameter_availability_beta() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: legacy_filter + in: query + x-fern-availability: beta + schema: { type: string } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let p = m.parameters.get("legacy_filter").expect("param missing"); + assert_eq!(p.availability, Some(Availability::Beta)); + } + + #[test] + fn test_parameter_openapi_deprecated_falls_back_to_deprecated_availability() { + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: [{ url: "https://x.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: legacy_filter + in: query + deprecated: true + schema: { type: string } + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "things", "list"); + let p = m.parameters.get("legacy_filter").expect("param missing"); + assert_eq!(p.availability, Some(Availability::Deprecated)); + assert!(p.deprecated, "raw deprecated flag is still preserved"); + } + + // ----------------------------------------------------------------------- + // x-fern-base-path + // ----------------------------------------------------------------------- + + /// Spec without `x-fern-base-path` → `RestDescription.base_path` is None. + #[test] + fn test_x_fern_base_path_absent_yields_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path, None); + } + + /// Spec with leading-slash `x-fern-base-path` is captured verbatim. + #[test] + fn test_x_fern_base_path_with_leading_slash_captured_verbatim() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: /v1 +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path.as_deref(), Some("/v1")); + } + + /// Spec without a leading slash on `x-fern-base-path` is captured as + /// authored — `build_url` normalizes slashes at request time so the + /// parser does not reshape the user's input. + #[test] + fn test_x_fern_base_path_without_leading_slash_captured_verbatim() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: api/public +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path.as_deref(), Some("api/public")); + } + + /// Empty / whitespace-only `x-fern-base-path` collapses to None so + /// the executor's slash-edge logic doesn't have to handle the empty + /// case. + #[test] + fn test_x_fern_base_path_empty_string_collapses_to_none() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: "" +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.base_path, None); + } + + /// `x-fern-base-path` does not affect the command tree — operations + /// are still grouped by `x-fern-sdk-group-name` only, not nested + /// under the base path. + #[test] + fn test_x_fern_base_path_does_not_affect_command_tree() { + let yaml = r#" +openapi: "3.0.0" +info: { title: t, version: "1.0" } +servers: [{ url: "https://api.example.com" }] +x-fern-base-path: /v1 +paths: + /things: + get: + x-fern-sdk-group-name: [things] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + // Resource grouping is unaffected — no `v1` namespace inserted. + assert!(doc.resources.contains_key("things")); + assert!(!doc.resources.contains_key("v1")); + // The operation's stored path is also unchanged — base_path is + // only applied at URL-construction time, not baked into method.path. + let m = &doc.resources["things"].methods["list"]; + assert_eq!(m.path, "/things"); + } + + /// `normalize_base_path` helper: trims surrounding whitespace, treats + /// empty/whitespace-only as absent, otherwise returns the raw value. + /// Direct coverage of the helper independent of YAML parsing. + #[test] + fn test_normalize_base_path() { + assert_eq!(normalize_base_path(None), None); + assert_eq!(normalize_base_path(Some("")), None); + assert_eq!(normalize_base_path(Some(" ")), None); + assert_eq!(normalize_base_path(Some("/v1")), Some("/v1".to_string())); + assert_eq!(normalize_base_path(Some("v1")), Some("v1".to_string())); + assert_eq!(normalize_base_path(Some(" /v1 ")), Some("/v1".to_string())); + } + + // ------------------------------------------------------------------ + // x-fern-sdk-return-value + // + // Mirrors upstream `FernOpenAPIExtension.RESPONSE_PROPERTY` — the + // extension is a string referencing a property on the response body. + // Stored on `RestMethod.return_value` as `Option`, with + // leading/trailing whitespace trimmed and empty/whitespace-only + // values normalized to `None` so downstream code only sees a + // resolvable path or nothing. + // ------------------------------------------------------------------ + + #[test] + fn test_operation_return_value_absent_is_none() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value, None, + "no x-fern-sdk-return-value → return_value is None (executor prints full body)", + ); + } + + #[test] + fn test_operation_return_value_top_level_path() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: data"); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.return_value.as_deref(), Some("data")); + } + + #[test] + fn test_operation_return_value_nested_dotted_path() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: result.items"); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value.as_deref(), + Some("result.items"), + "dotted paths are preserved verbatim; the executor walks them at runtime", + ); + } + + #[test] + fn test_operation_return_value_empty_string_is_none() { + // Empty / whitespace-only is meaningless for path resolution. + // Normalize to `None` so the executor can't be tripped into + // emitting a confusing "path '' did not resolve" error. + let doc = parse_op_with_extra(" x-fern-sdk-return-value: \"\""); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.return_value, None); + } + + #[test] + fn test_operation_return_value_whitespace_trimmed() { + let doc = parse_op_with_extra(" x-fern-sdk-return-value: \" data \""); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.return_value.as_deref(), + Some("data"), + "surrounding whitespace is trimmed; an inner space would still survive", + ); + } + + // ------------------------------------------------------------------ + // Named-server parsing — `x-fern-server-name` (v2) and `x-name` (v1). + // ------------------------------------------------------------------ + + #[test] + fn test_named_server_v2_spelling_is_parsed() { + // Fern v2 canonical spelling `x-fern-server-name` populates + // `Server.name`. The first server in declaration order remains + // the default — its URL is what drives `RestDescription.root_url` + // for the no-flag case. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: Production + description: "Production environment" + - url: "https://staging.example.com" + x-fern-server-name: Staging +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[0].url, "https://api.example.com"); + assert_eq!( + doc.servers[0].description.as_deref(), + Some("Production environment"), + ); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + let named: Vec<_> = doc.named_servers().collect(); + assert_eq!(named.len(), 2); + } + + #[test] + fn test_named_server_v1_alias_x_name_is_recognized() { + // Older specs that haven't migrated to `x-fern-server-name` use + // the legacy alias `x-name`. The parser accepts it for + // backwards compatibility. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: LegacyProd +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("LegacyProd")); + } + + #[test] + fn test_named_server_empty_v1_falls_through_to_v2() { + // Defensive parity: an `x-name: ""` on the same entry as a + // valid `x-fern-server-name: Production` must not shadow the + // v2 value. The parser treats empty/whitespace-only extensions + // as "absent" before applying the v1-over-v2 fallback, so a + // blank legacy alias falls through to the canonical Fern + // spelling instead of dropping the server's name entirely. + // Mirrors the existing `test_empty_and_whitespace_server_names_are_dropped` + // guarantee on the v2 side. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: "" + x-fern-server-name: Production + - url: "https://whitespace.example.com" + x-name: " " + x-fern-server-name: Staging +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 2); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + assert_eq!(doc.servers[1].name.as_deref(), Some("Staging")); + } + + #[test] + fn test_named_server_empty_v2_still_falls_back_to_v1() { + // Symmetric case: an empty `x-fern-server-name: ""` must not + // suppress a valid `x-name: OldProd` on the same entry. Even + // though v1 wins outright when both are present, this test + // pins the per-field trim+filter behavior so future refactors + // can't regress into the "first field always wins, even when + // blank" trap. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-name: OldProd + x-fern-server-name: "" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("OldProd")); + } + + #[test] + fn test_named_server_v1_wins_when_both_present() { + // When both v2 (`x-fern-server-name`) and v1 (`x-name`) are + // present on the same entry, v1 wins to mirror fern's + // `getExtension([SERVER_NAME_V1, SERVER_NAME_V2])` first-match + // semantics in + // `packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/convertServer.ts:72-75`. + // Fern's order is the source of truth — don't flip this even + // if v2-wins reads more naturally. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: NewName + x-name: OldName +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("OldName")); + } + + #[test] + fn test_no_named_servers_when_extensions_absent() { + // Plain OpenAPI servers without either extension carry no name. + // The CLI surface stays unchanged for these specs — no + // `--server` flag is exposed downstream. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 1); + assert!(doc.servers[0].name.is_none()); + assert_eq!(doc.named_servers().count(), 0); + } + + #[test] + fn test_per_operation_servers_override_is_captured() { + // Per-operation `servers:` blocks lower into + // `RestMethod.servers` independently of the top-level set, and + // they are authoritative for that operation (the executor + // resolves `--server ` against this list first when it's + // non-empty). + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://api.example.com" + x-fern-server-name: Production +paths: + /uploads: + post: + x-fern-sdk-group-name: ["uploads"] + x-fern-sdk-method-name: create + servers: + - url: "https://upload.example.com" + x-fern-server-name: Upload + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let m = first_method(&doc, "uploads", "create"); + assert_eq!(m.servers.len(), 1); + assert_eq!(m.servers[0].name.as_deref(), Some("Upload")); + assert_eq!(m.servers[0].url, "https://upload.example.com"); + // Top-level set is preserved separately. + assert_eq!(doc.servers.len(), 1); + assert_eq!(doc.servers[0].name.as_deref(), Some("Production")); + } + + #[test] + fn test_empty_and_whitespace_server_names_are_dropped() { + // Empty or whitespace-only `x-fern-server-name` / `x-name` + // values would leak into clap's allowed-list as blank strings + // and into the `Servers:` help block as a blank-named row. The + // parser trims and filters them at the source so downstream + // code never has to defend against this. + let yaml = r#" +openapi: "3.0.0" +info: { title: T, version: "1.0" } +servers: + - url: "https://blank.example" + x-fern-server-name: "" + - url: "https://whitespace.example" + x-fern-server-name: " " + - url: "https://blank-legacy.example" + x-name: "" + - url: "https://trimmed.example" + x-fern-server-name: " Production " +paths: + /things: + get: + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + responses: { "200": { description: ok } } +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.servers.len(), 4, "unnamed entries are still preserved"); + // First three entries' names are filtered out (empty after trim). + assert!(doc.servers[0].name.is_none()); + assert!(doc.servers[1].name.is_none()); + assert!(doc.servers[2].name.is_none()); + // Surrounding whitespace is trimmed. + assert_eq!(doc.servers[3].name.as_deref(), Some("Production")); + // Only the trimmed-but-non-empty entry is selectable via --server. + assert_eq!(doc.named_servers().count(), 1); + } + + // ------------------------------------------------------------------ + // x-fern-enum — per-value overrides on parameter enums + // + // Mirrors the upstream Fern importer + // (packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/ + // extensions/getFernEnum.ts), which models the extension as + // `Record`. cli-sdk + // consumes only `name` (display alias) and `description`; `casing` + // is an SDK-codegen concern. + // ------------------------------------------------------------------ + + fn parse_users_list_user_type(extra_indented: &str) -> MethodParameter { + let yaml = format!( + r#" +openapi: "3.0.0" +info: {{ title: T, version: "1.0" }} +servers: [{{ url: "https://x.com" }}] +paths: + /users: + get: + x-fern-sdk-group-name: ["users"] + x-fern-sdk-method-name: list + parameters: + - name: user_type + in: query + schema: + type: string + enum: [all, managed, external] +{extra_indented} + responses: {{ "200": {{ description: ok }} }} +"# + ); + let doc = load_openapi_spec(&yaml, "t").unwrap(); + let m = first_method(&doc, "users", "list"); + m.parameters + .get("user_type") + .expect("user_type param missing") + .clone() + } + + /// Absent extension: `fern_enum` stays `None` and the wire values + /// flow through unchanged. + #[test] + fn test_x_fern_enum_absent_yields_none() { + let p = parse_users_list_user_type(""); + assert!( + p.fern_enum.is_none(), + "no x-fern-enum should produce None, got {:?}", + p.fern_enum + ); + assert_eq!( + p.enum_values.as_deref(), + Some(["all", "managed", "external"].as_slice()) + .map(|s| s.iter().map(|v| v.to_string()).collect::>()) + .as_deref(), + ); + } + + /// Every value carries both `name` and `description`: the parser + /// should preserve each per-value override keyed by the wire value. + #[test] + fn test_x_fern_enum_full_override_round_trips_per_value_fields() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: All + description: Every user, including external collaborators. + managed: + name: Managed + description: Users your enterprise manages. + external: + name: External + description: External collaborators only.", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + assert_eq!(map.len(), 3, "every enum value should have an entry"); + + let all = map.get("all").expect("`all` entry missing"); + assert_eq!(all.display_name.as_deref(), Some("All")); + assert_eq!( + all.description.as_deref(), + Some("Every user, including external collaborators."), + ); + + let managed = map.get("managed").expect("`managed` entry missing"); + assert_eq!(managed.display_name.as_deref(), Some("Managed")); + assert_eq!( + managed.description.as_deref(), + Some("Users your enterprise manages."), + ); + + let external = map.get("external").expect("`external` entry missing"); + assert_eq!(external.display_name.as_deref(), Some("External")); + assert_eq!( + external.description.as_deref(), + Some("External collaborators only."), + ); + } + + /// Partial override: only some wire values appear under `x-fern-enum`, + /// and listed entries may set only one of `name` / `description`. + /// Missing entries must NOT synthesize blank overrides — they stay + /// out of the map so downstream code falls back to the raw wire + /// value with no description. + #[test] + fn test_x_fern_enum_partial_override_skips_missing_entries() { + let p = parse_users_list_user_type( + " x-fern-enum: + managed: + description: Users your enterprise manages. + external: + name: External", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + + assert!( + !map.contains_key("all"), + "values absent from x-fern-enum must not appear in the map; got {map:?}", + ); + + let managed = map.get("managed").expect("`managed` entry missing"); + assert_eq!( + managed.display_name, None, + "`managed` set only description; display_name should remain None", + ); + assert_eq!( + managed.description.as_deref(), + Some("Users your enterprise manages."), + ); + + let external = map.get("external").expect("`external` entry missing"); + assert_eq!(external.display_name.as_deref(), Some("External")); + assert_eq!( + external.description, None, + "`external` set only name; description should remain None", + ); + } + + /// Empty / whitespace-only `name` and `description` strings are + /// treated the same as absent, and an entry with both empty fields + /// is dropped entirely. Without this guard, downstream clap rendering + /// would emit empty help strings and a meaningless display alias. + #[test] + fn test_x_fern_enum_drops_empty_entries() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: \"\" + description: \" \" + managed: + name: Managed", + ); + let map = p.fern_enum.expect("x-fern-enum should be parsed"); + assert!( + !map.contains_key("all"), + "entries with only whitespace fields must be dropped, got {map:?}", + ); + assert!(map.contains_key("managed")); + } + + /// `resolve_enum_display_to_wire` is the bridge between the CLI + /// surface (which accepts either display name or wire value) and + /// the HTTP layer (which only ever sees the wire value). This test + /// pins the contract end to end: parser → `MethodParameter` → + /// resolution. + #[test] + fn test_x_fern_enum_display_to_wire_round_trip() { + let p = parse_users_list_user_type( + " x-fern-enum: + all: + name: All + managed: + name: Managed + description: Managed users. + external: {}", + ); + + // Display name → wire value + assert_eq!(p.resolve_enum_display_to_wire("All").as_ref(), "all"); + assert_eq!( + p.resolve_enum_display_to_wire("Managed").as_ref(), + "managed" + ); + + // Wire value passes through untouched + assert_eq!(p.resolve_enum_display_to_wire("all").as_ref(), "all"); + assert_eq!( + p.resolve_enum_display_to_wire("external").as_ref(), + "external", + "value with empty x-fern-enum entry must round-trip as-is", + ); + + // Unknown input is returned unchanged (clap rejects this before + // we ever hit the executor; we only assert non-mutation here). + assert_eq!(p.resolve_enum_display_to_wire("Bogus").as_ref(), "Bogus"); + } + + /// Without `x-fern-enum`, the resolver must be a pure identity — + /// the param-level helper should never block requests on enums + /// that don't opt into the extension. + #[test] + fn test_resolve_enum_display_to_wire_identity_without_fern_enum() { + let p = parse_users_list_user_type(""); + assert!(p.fern_enum.is_none()); + assert_eq!( + p.resolve_enum_display_to_wire("managed").as_ref(), + "managed" + ); + assert_eq!( + p.resolve_enum_display_to_wire("unknown").as_ref(), + "unknown" + ); + } + + // ----------------------------------------------------------------- + // x-fern-sdk-variables / x-fern-sdk-variable + // ----------------------------------------------------------------- + + #[test] + fn test_sdk_variables_parses_string_entries_with_descriptions() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Garden API + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + gardenId: + type: string + description: The garden tenant identifier. + zoneId: + type: string +paths: {} +"#; + let doc = load_openapi_spec(yaml, "garden").unwrap(); + assert_eq!(doc.sdk_variables.len(), 2, "expected two declared variables"); + // Preserves declaration order so --help renders deterministically. + assert_eq!(doc.sdk_variables[0].name, "gardenId"); + assert_eq!(doc.sdk_variables[0].ty, "string"); + assert_eq!( + doc.sdk_variables[0].description.as_deref(), + Some("The garden tenant identifier."), + ); + assert_eq!(doc.sdk_variables[1].name, "zoneId"); + assert_eq!(doc.sdk_variables[1].description, None); + } + + #[test] + fn test_sdk_variables_skips_non_string_types() { + // Fern docs say only strings are supported today. Non-string + // entries are dropped (with a warn-level log); the parser stays + // permissive so downstream behavior degrades to "missing flag" + // rather than a hard load failure. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + count: + type: integer + name: + type: string +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert_eq!(doc.sdk_variables.len(), 1); + assert_eq!(doc.sdk_variables[0].name, "name"); + } + + #[test] + fn test_sdk_variable_marks_path_parameter() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Garden API + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + gardenId: + type: string +paths: + /gardens/{gardenId}/zones: + get: + operationId: zones-list + x-fern-sdk-group-name: ["zones"] + x-fern-sdk-method-name: list + parameters: + - name: gardenId + in: path + required: true + x-fern-sdk-variable: gardenId + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "garden").unwrap(); + let method = doc + .resources + .get("zones") + .and_then(|r| r.methods.get("list")) + .expect("zones.list missing"); + let param = method + .parameters + .get("gardenId") + .expect("gardenId param missing"); + assert_eq!( + param.variable_reference.as_deref(), + Some("gardenId"), + "path parameter should be marked variable-bound", + ); + } + + #[test] + fn test_sdk_variable_on_non_path_parameter_is_ignored() { + // Fern's IR only honors variable references on `in: path` + // parameters; references on query/header/cookie are logged and + // dropped so the parameter still surfaces as a normal flag. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-sdk-variables: + tenant: + type: string +paths: + /things: + get: + operationId: things-list + x-fern-sdk-group-name: ["things"] + x-fern-sdk-method-name: list + parameters: + - name: tenant + in: query + x-fern-sdk-variable: tenant + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let method = doc + .resources + .get("things") + .and_then(|r| r.methods.get("list")) + .expect("things.list missing"); + let param = method.parameters.get("tenant").expect("tenant missing"); + assert!( + param.variable_reference.is_none(), + "x-fern-sdk-variable on a query parameter should NOT mark it variable-bound", + ); + } + + #[test] + fn test_plain_path_param_without_variable_reference() { + // Regression guard: a path parameter without `x-fern-sdk-variable` + // must continue to surface as a normal per-operation flag (no + // accidental variable_reference inheritance). + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /files/{file_id}: + get: + operationId: files-get + x-fern-sdk-group-name: ["files"] + x-fern-sdk-method-name: get + parameters: + - name: file_id + in: path + required: true + schema: { type: string } + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let method = doc + .resources + .get("files") + .and_then(|r| r.methods.get("get")) + .expect("files.get missing"); + let param = method.parameters.get("file_id").expect("file_id missing"); + assert_eq!(param.variable_reference, None); + assert_eq!(param.location.as_deref(), Some("path")); + } + + #[test] + fn test_sdk_variables_absent_yields_empty_vec() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: {} +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + assert!(doc.sdk_variables.is_empty()); + } + + // --------------------------------------------------------------------- + // x-fern-streaming parsing + // + // Exercises every form the upstream importer recognizes plus the + // failure modes we explicitly validate. Each test isolates one + // shape so a regression points at the exact branch in + // `parse_streaming_extension`. + // --------------------------------------------------------------------- + + /// Shared helper to parse a spec stub with the given + /// `x-fern-streaming` value and return the resolved streaming + /// config for a single hardcoded operation. + fn streaming_for(extension_yaml: &str) -> Result, CliError> { + let yaml = format!( + r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /stream: + post: + operationId: streamChat + x-fern-streaming: {extension_yaml} + responses: + "200": + description: ok +"# + ); + let doc = load_openapi_spec(&yaml, "stream-spec")?; + Ok(doc + .resources + .get("stream") + .and_then(|r| r.methods.get("stream-chat")) + .and_then(|m| m.streaming.clone())) + } + + #[test] + fn test_streaming_boolean_true_is_ndjson() { + // Upstream's boolean shorthand picks NDJSON (so that callers + // who haven't chosen a wire format don't get SSE semantics). + let result = streaming_for("true").unwrap(); + assert_eq!(result, Some(StreamingConfig::Json { terminator: None })); + } + + #[test] + fn test_streaming_boolean_false_is_none() { + let result = streaming_for("false").unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_streaming_object_format_sse() { + let result = streaming_for("{ format: sse }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Sse { terminator: None })); + } + + #[test] + fn test_streaming_object_format_json() { + let result = streaming_for("{ format: json }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Json { terminator: None })); + } + + #[test] + fn test_streaming_object_sse_with_terminator() { + let result = streaming_for(r#"{ format: sse, terminator: "[DONE]" }"#).unwrap(); + assert_eq!( + result, + Some(StreamingConfig::Sse { + terminator: Some("[DONE]".to_string()) + }) + ); + } + + #[test] + fn test_streaming_object_default_format_is_json() { + // Matches the typed SDKs (TS / C#) and the upstream importer: + // an object with no `format` field defaults to NDJSON, the + // same as the boolean shorthand. Callers that want SSE must + // declare `format: sse` explicitly. + let result = streaming_for(r#"{ terminator: "[END]" }"#).unwrap(); + assert_eq!( + result, + Some(StreamingConfig::Json { + terminator: Some("[END]".to_string()) + }) + ); + } + + #[test] + fn test_streaming_object_format_text() { + // `format: text` mirrors Fern IR's `TextStreamChunk` variant + // (see `packages/ir-sdk/.../http.yml`). No terminator field + // and no payload type — raw lines are emitted verbatim. + let result = streaming_for("{ format: text }").unwrap(); + assert_eq!(result, Some(StreamingConfig::Text)); + } + + #[test] + fn test_streaming_text_rejects_terminator() { + // `TextStreamChunk` has no `terminator` field; flagging it at + // parse time keeps misconfigurations from silently no-op'ing + // at runtime. + let err = streaming_for(r#"{ format: text, terminator: "EOF" }"#).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`terminator` is not supported for `format: text`"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_streaming_invalid_format_errors() { + let err = streaming_for("{ format: websocket }").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`format` must be `sse`, `json`, or `text`"), + "unexpected error: {msg}" + ); + assert!(msg.contains("websocket"), "unexpected error: {msg}"); + } + + #[test] + fn test_streaming_invalid_kind_errors() { + // A scalar that isn't a boolean is meaningless. + let err = streaming_for(r#""sse""#).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("expected a boolean or an object"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_streaming_and_pagination_mutually_exclusive() { + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /events: + get: + operationId: listEvents + x-fern-streaming: true + x-fern-pagination: + cursor: cursor + next_cursor: $response.next_cursor + results: $response.events + responses: + "200": + description: ok +"#; + let err = load_openapi_spec(yaml, "stream-page").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("`x-fern-streaming`") + && msg.contains("`x-fern-pagination`") + && msg.contains("mutually exclusive"), + "expected mutual-exclusion error, got: {msg}" + ); + } + + // --------------------------------------------------------------- + // `x-fern-retries` resolution + // --------------------------------------------------------------- + + #[test] + fn test_resolve_retries_absent_returns_none() { + // Neither root nor op declared the extension. Operations + // without an explicit policy stay opt-in — the executor + // returns `None` and skips the retry wrapper entirely. + let cfg = resolve_retries_extension(None, None, "getFoo").unwrap(); + assert!(cfg.is_none()); + } + + #[test] + fn test_resolve_retries_op_true_no_root_uses_defaults() { + // `x-fern-retries: true` on an op without a root block + // materializes the cli-sdk runtime defaults (max=2, + // base=250ms, factor=2.0, jitter=0.1) — conservative for an + // interactive CLI where users expect fast, observable failures. + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("op:true materializes defaults"); + assert_eq!(cfg, RetriesConfig::default()); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, crate::openapi::discovery::DEFAULT_RETRY_MAX_ATTEMPTS); + assert_eq!(cfg.base_delay_ms, crate::openapi::discovery::DEFAULT_RETRY_BASE_DELAY_MS); + } + + #[test] + fn test_resolve_retries_op_false_disables_regardless_of_root() { + // Per-op `false` short-circuits to `disabled` even when the + // root block enabled retries (op specificity > spec defaults). + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\n"); + let op = serde_yaml::Value::Bool(false); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op:false yields explicit disabled config"); + assert!(!cfg.enabled); + assert_eq!(cfg, RetriesConfig::disabled()); + } + + #[test] + fn test_resolve_retries_op_missing_inherits_root_object() { + // Op block missing → inherit the root config verbatim. + let root = yaml("max_attempts: 7\nbase_delay_ms: 250\nfactor: 3.0\njitter: 0.0\n"); + let cfg = resolve_retries_extension(None, Some(&root), "getFoo") + .unwrap() + .expect("missing op inherits root config"); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, 7); + assert_eq!(cfg.base_delay_ms, 250); + assert!((cfg.factor - 3.0).abs() < f64::EPSILON); + assert!((cfg.jitter - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_retries_op_true_inherits_root_object() { + // `x-fern-retries: true` on the op should adopt the root + // baseline (not start over from defaults). This is the + // shorthand authors use to opt every endpoint into a spec-wide + // retry policy. + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\n"); + let op = serde_yaml::Value::Bool(true); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op:true adopts root baseline"); + assert_eq!(cfg.max_attempts, 5); + assert_eq!(cfg.base_delay_ms, 1000); + } + + #[test] + fn test_resolve_retries_op_object_overrides_root_field_by_field() { + // Per-op object merges over the root baseline. Fields the op + // doesn't mention keep the root values; fields it does mention + // override. Matches the pagination resolver's field-by-field + // merge semantics. + let root = yaml("max_attempts: 5\nbase_delay_ms: 1000\nfactor: 2.0\njitter: 0.2\n"); + let op = yaml("max_attempts: 10\n"); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("op object merges over root"); + assert_eq!(cfg.max_attempts, 10, "op overrides root"); + assert_eq!(cfg.base_delay_ms, 1000, "root inherited"); + assert!((cfg.factor - 2.0).abs() < f64::EPSILON); + assert!((cfg.jitter - 0.2).abs() < f64::EPSILON); + } + + #[test] + fn test_resolve_retries_root_disabled_inherited_by_default() { + // Spec-root `{ disabled: true }` should propagate by default + // to operations that don't declare their own block. + let root = yaml("disabled: true\n"); + let cfg = resolve_retries_extension(None, Some(&root), "getFoo") + .unwrap() + .expect("disabled root inherited"); + assert!(!cfg.enabled); + } + + #[test] + fn test_resolve_retries_op_object_reenables_after_root_disabled() { + // An explicit per-op object takes precedence over a disabled + // root. Authors can opt a single endpoint back in even when + // the spec-level policy is off. + let root = yaml("disabled: true\n"); + let op = yaml("max_attempts: 4\n"); + let cfg = resolve_retries_extension(Some(&op), Some(&root), "getFoo") + .unwrap() + .expect("per-op object re-enables"); + assert!(cfg.enabled); + assert_eq!(cfg.max_attempts, 4); + } + + #[test] + fn test_resolve_retries_upstream_disabled_object() { + // Canonical upstream shape: `{ disabled: true }` per + // `getFernRetriesExtension.ts`. We must round-trip it. + let op = yaml("disabled: true\n"); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("disabled:true yields explicit disabled"); + assert_eq!(cfg, RetriesConfig::disabled()); + } + + #[test] + fn test_resolve_retries_max_zero_treated_as_disabled() { + // `max_attempts: 0` means "never retry" — equivalent to + // `disabled: true`. Normalize here so the executor doesn't + // have to special-case the count. + let op = yaml("max_attempts: 0\n"); + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .expect("max=0 normalizes to disabled"); + assert!(!cfg.enabled); + } + + #[test] + fn test_resolve_retries_max_attempts_alias_spellings() { + // `max` / `max-attempts` / `max_attempts` are interchangeable + // (forward-compat with upstream which may pick any one). + let op_snake = yaml("max_attempts: 6\n"); + let op_kebab = yaml("max-attempts: 6\n"); + let op_short = yaml("max: 6\n"); + for op in [op_snake, op_kebab, op_short] { + let cfg = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap() + .unwrap(); + assert_eq!(cfg.max_attempts, 6); + } + } + + #[test] + fn test_resolve_retries_invalid_max_negative_errors() { + // Negative values must be rejected (u32 can't hold them) — + // surface a clear discovery error. + let op = yaml("max_attempts: -1\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("max_attempts"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_factor_below_one_errors() { + // Backoff factor < 1.0 would mean delays shrink, which is + // nonsensical. Reject to catch authoring bugs. + let op = yaml("factor: 0.5\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("factor"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_jitter_out_of_range_errors() { + // Jitter is a fraction in [0, 1]; anything else is an + // authoring bug. + let op = yaml("jitter: 1.5\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("jitter"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_shape_errors() { + // Arrays/strings are not a valid shape. Mirror the + // pagination resolver's strict typing. + let op = yaml("- 1\n- 2\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("x-fern-retries"), "{msg}"); + } + + #[test] + fn test_resolve_retries_invalid_disabled_non_bool_errors() { + // `disabled` must be boolean. Surface authoring bugs early. + let op = yaml("disabled: yes-please\n"); + let err = resolve_retries_extension(Some(&op), None, "getFoo") + .unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("disabled"), "{msg}"); + } + + #[test] + fn test_load_openapi_spec_with_root_retries() { + // End-to-end: a spec with a root `x-fern-retries` block and + // no per-op blocks. Every operation inherits the root config. + let yaml = r#" +openapi: "3.0.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +x-fern-retries: + max_attempts: 5 + base_delay_ms: 250 +paths: + /foo: + get: + operationId: getFoo + x-fern-sdk-method-name: get + x-fern-sdk-group-name: foo + responses: + "200": + description: ok +"#; + let doc = load_openapi_spec(yaml, "t").unwrap(); + let root_cfg = doc.retries.as_ref().expect("root retries set"); + assert_eq!(root_cfg.max_attempts, 5); + assert_eq!(root_cfg.base_delay_ms, 250); + + let foo = doc.resources.get("foo").expect("foo resource"); + let get = foo + .methods + .values() + .find(|m| m.id.as_deref() == Some("getFoo")) + .expect("getFoo"); + let op_cfg = get.retries.as_ref().expect("op inherited retries"); + assert_eq!(op_cfg.max_attempts, 5); + assert_eq!(op_cfg.base_delay_ms, 250); + } + + // ------------------------------------------------------------------ + // x-fern-audiences (operation level) + // + // Mirrors fern-api/fern's OpenAPI importer + // (`packages/cli/api-importers/openapi/openapi-ir-parser/src/openapi/v3/converters/operation/convertHttpOperation.ts:330`): + // + // audiences: getExtension(operation, FernOpenAPIExtension.AUDIENCES) ?? [] + // + // — i.e. an array-of-strings extension on the operation object, + // defaulting to `[]` when missing. Filtering itself happens at the + // command-tree-build stage (see + // `crate::openapi::commands::filter_doc_by_audiences`), so the + // parser's job is to faithfully surface what the spec declares. + // ------------------------------------------------------------------ + + #[test] + fn test_x_fern_audiences_missing_yields_empty_vec() { + let doc = parse_op_with_extra(""); + let m = first_method(&doc, "things", "list"); + assert!( + m.audiences.is_empty(), + "missing x-fern-audiences should yield empty vec, got: {:?}", + m.audiences + ); + } + + #[test] + fn test_x_fern_audiences_explicit_empty_yields_empty_vec() { + // Mirrors fern: an explicitly empty `x-fern-audiences: []` is + // indistinguishable from "missing" — both lower to `[]` in the IR. + let doc = parse_op_with_extra(" x-fern-audiences: []"); + let m = first_method(&doc, "things", "list"); + assert!(m.audiences.is_empty()); + } + + #[test] + fn test_x_fern_audiences_single_value() { + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!(m.audiences, vec!["public".to_string()]); + } + + #[test] + fn test_x_fern_audiences_multiple_values_preserve_order() { + // fern stores audiences as a `string[]` without dedup or sort + // (`convertHttpOperation.ts:330` is a direct passthrough). We + // do the same — preserve user-declared order so downstream + // consumers can rely on the spec's listing. + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public\n - internal\n - beta", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.audiences, + vec![ + "public".to_string(), + "internal".to_string(), + "beta".to_string(), + ], + ); + } + + #[test] + fn test_x_fern_audiences_preserves_duplicate_entries() { + // Defensive: don't silently dedup. The fern importer passes + // the raw array through, so mirroring that means duplicate + // entries land verbatim in the IR (the audience filter does + // its own membership check and is dedup-tolerant). + let doc = parse_op_with_extra( + " x-fern-audiences:\n - public\n - public", + ); + let m = first_method(&doc, "things", "list"); + assert_eq!( + m.audiences, + vec!["public".to_string(), "public".to_string()], + ); + } + // -- Real-world OpenAPI 3.1 specs (parse sweep) ---------------------- + // + // These fixtures are real customer/prospect specs that declare + // `openapi: 3.1.0`. They sweep the new 3.1 surface area (type arrays, + // numeric exclusive bounds, const, composition, webhooks, schema-level + // examples) against actual wire shapes rather than synthetic snippets. + + // -- JSON Schema composition (oneOf / anyOf / allOf) ----------------- + + #[test] + fn test_composition_one_of_captures_branches() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: integer + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.one_of.len(), 2); + assert_eq!(prop.one_of[0].prop_type.as_deref(), Some("string")); + assert_eq!(prop.one_of[1].prop_type.as_deref(), Some("integer")); + } + + #[test] + fn test_composition_any_of_and_all_of() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + extra: + type: string + anyOf: + - type: number + - type: string + "##, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.all_of.len(), 2); + assert_eq!(prop.all_of[0].schema_ref.as_deref(), Some("Base")); + assert_eq!(prop.any_of.len(), 2); + assert_eq!(prop.any_of[0].prop_type.as_deref(), Some("number")); + } + + #[test] + fn test_composition_at_parent_json_schema_level() { + // Component-schema roots can themselves be a oneOf/anyOf/allOf (heavy + // pattern in Auth0's spec). The IR's parent JsonSchema must capture + // these, not just the property-level variants. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + extra: + type: string + "##, + ) + .unwrap(); + let s = convert_schema_object(&obj); + assert_eq!(s.all_of.len(), 2); + assert_eq!(s.all_of[0].schema_ref.as_deref(), Some("Base")); + assert_eq!(s.all_of[1].prop_type.as_deref(), Some("object")); + } + + #[test] + fn test_composition_nullable_via_oneof_with_null_type() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: "null" + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.one_of.len(), 2); + assert_eq!(prop.one_of[1].prop_type.as_deref(), Some("null")); + } + + // -- ADR-0004: allOf flattening into per-field flags ----------------- + + #[test] + fn test_all_of_flattens_inline_branches_at_root() { + // Root-level allOf with two inline branches. The flattener must + // emit flags from both, not silently drop them. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + allOf: + - type: object + required: [id] + properties: + id: + type: string + - type: object + properties: + extra: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params.contains_key("id"), "id from first branch: {params:?}"); + assert!(params.contains_key("extra"), "extra from second branch: {params:?}"); + assert!(params["id"].required, "id was required in base branch"); + assert!(!params["extra"].required, "extra was not in any required list"); + } + + #[test] + fn test_all_of_resolves_ref_branches() { + // The dominant Box pattern: `allOf: [{$ref: Base}, {inline overlay}]`. + // The flattener must resolve the ref through component_schemas and + // merge the resolved properties with the overlay's. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - $ref: "#/components/schemas/Base" + - type: object + properties: + extra: + type: integer + "##, + ) + .unwrap(); + let base: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + required: [id] + properties: + id: + type: string + created_at: + type: string + "#, + ) + .unwrap(); + let mut components = HashMap::new(); + components.insert("Base".to_string(), base); + let params = flatten_body_params(&schema, &components, 0); + assert!(params.contains_key("id")); + assert!(params.contains_key("created_at")); + assert!(params.contains_key("extra")); + assert!(params["id"].required, "id required via base $ref"); + assert!(!params["extra"].required); + assert_eq!(params["extra"].param_type.as_deref(), Some("integer")); + } + + #[test] + fn test_all_of_last_branch_wins_on_duplicate_property() { + // When two branches declare the same property name, the latter + // wins — including type/enum constraints. This matches our + // last-branch-wins merge convention from ADR-0004. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - type: object + properties: + status: + type: string + description: Base status (no enum). + - type: object + properties: + status: + type: string + enum: [draft, sent] + description: Overlay status with enum. + "##, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let status = ¶ms["status"]; + assert_eq!( + status.enum_values.as_deref(), + Some(["draft".to_string(), "sent".to_string()].as_slice()), + "overlay's enum should win: {status:?}", + ); + } + + #[test] + fn test_all_of_unions_required_arrays_across_branches() { + // `required: [a]` in branch 1 plus `required: [b]` in branch 2 + // means BOTH a and b are required overall. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + allOf: + - type: object + required: [a] + properties: + a: + type: string + - type: object + required: [b] + properties: + b: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params["a"].required, "a required from branch 1"); + assert!(params["b"].required, "b required from branch 2"); + } + + #[test] + fn test_all_of_inside_object_property_flattens_dot_notation() { + // The "property-level allOf" case: `attachment: {type: object, + // allOf: [...]}` should produce `--attachment.url`, etc., not a + // single `--attachment` blob. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + type: object + properties: + attachment: + type: object + allOf: + - $ref: "#/components/schemas/AttachmentBase" + - type: object + properties: + checksum: + type: string + "##, + ) + .unwrap(); + let base: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + url: + type: string + "#, + ) + .unwrap(); + let mut components = HashMap::new(); + components.insert("AttachmentBase".to_string(), base); + let params = flatten_body_params(&schema, &components, 0); + assert!(params.contains_key("attachment.url"), "{params:?}"); + assert!(params.contains_key("attachment.checksum"), "{params:?}"); + } + + #[test] + fn test_all_of_is_transparent_to_max_body_depth() { + // ADR-0004: `allOf` does NOT consume MAX_BODY_DEPTH. A spec that + // chains six allOf wrappers above the actual properties should + // still flatten the leaf fields. (MAX_BODY_DEPTH = 3, but + // crossing 6 allOf boundaries doesn't decrement it.) + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + allOf: + - allOf: + - allOf: + - allOf: + - allOf: + - allOf: + - type: object + required: [leaf] + properties: + leaf: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!( + params.contains_key("leaf"), + "deep allOf chain should still flatten: {params:?}", + ); + assert!(params["leaf"].required); + } + + #[test] + fn test_all_of_unresolvable_ref_skipped_silently() { + // A $ref pointing to a nonexistent component schema should not + // crash the flattener — log + skip is the policy. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + allOf: + - $ref: "#/components/schemas/NonExistent" + - type: object + properties: + alive: + type: string + "##, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params.contains_key("alive"), "second branch still flattens"); + assert_eq!(params.len(), 1, "no ghost property from unresolved ref"); + } + + // -- ADR-0005: nullable-union promotion ------------------------------ + + #[test] + fn test_recognize_nullable_union_inline_any_of() { + // Pydantic / Devin pattern: `anyOf: [{type: string}, {type: 'null'}]`. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + anyOf: + - type: string + - type: "null" + "#, + ) + .unwrap(); + assert_eq!( + recognize_nullable_union(&obj, &HashMap::new()), + Some("string"), + ); + } + + #[test] + fn test_recognize_nullable_union_inline_one_of() { + // Same shape via `oneOf` — same semantics for the nullable case. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: integer + - type: "null" + "#, + ) + .unwrap(); + assert_eq!( + recognize_nullable_union(&obj, &HashMap::new()), + Some("integer"), + ); + } + + #[test] + fn test_recognize_nullable_union_with_ref_to_scalar() { + // The non-null branch can be a `$ref` resolving to a scalar + // component schema. The recognizer follows the ref one level. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + oneOf: + - $ref: "#/components/schemas/ThreadIdString" + - type: "null" + "##, + ) + .unwrap(); + let base: OpenApiSchemaObject = serde_yaml::from_str("type: string\n").unwrap(); + let mut components = HashMap::new(); + components.insert("ThreadIdString".to_string(), base); + assert_eq!( + recognize_nullable_union(&obj, &components), + Some("string"), + ); + } + + #[test] + fn test_recognize_nullable_union_30_idiom_with_nullable_branch() { + // OpenAPI 3.0 idiom: the "null branch" is a standalone + // `{nullable: true}` with no concrete type. The recognizer + // accepts this as equivalent to `{type: 'null'}`. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + anyOf: + - type: number + - nullable: true + "#, + ) + .unwrap(); + assert_eq!( + recognize_nullable_union(&obj, &HashMap::new()), + Some("number"), + ); + } + + #[test] + fn test_recognize_nullable_union_mixed_scalar_types_is_not_promoted() { + // `[string, integer, null]` is a true union with a null branch — + // not a nullable scalar. The recognizer must reject this. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + anyOf: + - type: string + - type: integer + - type: "null" + "#, + ) + .unwrap(); + assert_eq!(recognize_nullable_union(&obj, &HashMap::new()), None); + } + + #[test] + fn test_recognize_nullable_union_no_null_branch_is_not_promoted() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + anyOf: + - type: string + - type: integer + "#, + ) + .unwrap(); + assert_eq!(recognize_nullable_union(&obj, &HashMap::new()), None); + } + + #[test] + fn test_recognize_nullable_union_all_of_never_promoted() { + // `allOf: [scalar, null]` is intersection-with-null — empty. + // The recognizer must NOT promote `allOf` even when one branch + // is a null sentinel (it would be a degenerate spec anyway). + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + allOf: + - type: string + - type: "null" + "#, + ) + .unwrap(); + assert_eq!(recognize_nullable_union(&obj, &HashMap::new()), None); + } + + #[test] + fn test_recognize_nullable_union_two_null_branches_not_promoted() { + // Two null branches is a malformed spec; recognizer fails closed. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + anyOf: + - type: "null" + - type: "null" + "#, + ) + .unwrap(); + assert_eq!(recognize_nullable_union(&obj, &HashMap::new()), None); + } + + #[test] + fn test_recognize_nullable_union_ref_to_non_scalar_not_promoted() { + // If the non-null branch's `$ref` resolves to an object/array, + // it doesn't reduce to a scalar — fail closed. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + anyOf: + - $ref: "#/components/schemas/Big" + - type: "null" + "##, + ) + .unwrap(); + let big: OpenApiSchemaObject = serde_yaml::from_str("type: object\n").unwrap(); + let mut components = HashMap::new(); + components.insert("Big".to_string(), big); + assert_eq!(recognize_nullable_union(&obj, &components), None); + } + + // -- resolve_ref_chain ---------------------------------------------------- + + #[test] + fn test_resolve_ref_chain_single_hop() { + let schema: OpenApiSchemaObject = + serde_yaml::from_str(r##"$ref: "#/components/schemas/Foo""##).unwrap(); + let foo: OpenApiSchemaObject = serde_yaml::from_str("type: string\n").unwrap(); + let mut components = HashMap::new(); + components.insert("Foo".to_string(), foo); + let resolved = resolve_ref_chain(&schema, &components).unwrap(); + assert_eq!(resolved.schema_type(), Some("string")); + } + + #[test] + fn test_resolve_ref_chain_multi_hop() { + // A -> B -> C (terminal: type: array, items.type: string) + let a: OpenApiSchemaObject = + serde_yaml::from_str(r##"$ref: "#/components/schemas/B""##).unwrap(); + let b: OpenApiSchemaObject = + serde_yaml::from_str(r##"$ref: "#/components/schemas/C""##).unwrap(); + let c: OpenApiSchemaObject = + serde_yaml::from_str("type: array\nitems:\n type: string\n").unwrap(); + let mut components = HashMap::new(); + components.insert("B".to_string(), b); + components.insert("C".to_string(), c); + let resolved = resolve_ref_chain(&a, &components).unwrap(); + assert_eq!(resolved.schema_type(), Some("array")); + } + + #[test] + fn test_resolve_ref_chain_already_terminal() { + let schema: OpenApiSchemaObject = serde_yaml::from_str("type: integer\n").unwrap(); + let components = HashMap::new(); + let resolved = resolve_ref_chain(&schema, &components).unwrap(); + assert_eq!(resolved.schema_type(), Some("integer")); + } + + #[test] + fn test_resolve_ref_chain_broken_returns_none() { + let schema: OpenApiSchemaObject = + serde_yaml::from_str(r##"$ref: "#/components/schemas/Missing""##).unwrap(); + let components = HashMap::new(); + assert!(resolve_ref_chain(&schema, &components).is_none()); + } + + // -- recognize_scalar_or_array_union --------------------------------------- + + #[test] + fn test_scalar_or_array_union_oneof() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: array + items: + type: string + "#, + ) + .unwrap(); + assert_eq!(recognize_scalar_or_array_union(&obj, &HashMap::new()), Some("string")); + } + + #[test] + fn test_scalar_or_array_union_anyof() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + anyOf: + - type: string + - type: array + items: + type: string + "#, + ) + .unwrap(); + assert_eq!(recognize_scalar_or_array_union(&obj, &HashMap::new()), Some("string")); + } + + #[test] + fn test_scalar_or_array_union_with_null() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: array + items: + type: string + - type: "null" + "#, + ) + .unwrap(); + assert_eq!(recognize_scalar_or_array_union(&obj, &HashMap::new()), Some("string")); + } + + #[test] + fn test_scalar_or_array_union_not_matching() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: integer + "#, + ) + .unwrap(); + assert!(recognize_scalar_or_array_union(&obj, &HashMap::new()).is_none()); + } + + #[test] + fn test_scalar_or_array_union_rejects_extra_branches() { + // oneOf [string, array, object] must NOT match — the + // object branch is not representable as a repeated flag. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: array + items: + type: string + - type: object + "#, + ) + .unwrap(); + assert!(recognize_scalar_or_array_union(&obj, &HashMap::new()).is_none()); + } + + #[test] + fn test_scalar_or_array_union_rejects_mismatched_array() { + // oneOf [string, array, array] must NOT match — + // multiple array branches are ambiguous. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: array + items: + type: integer + - type: array + items: + type: string + "#, + ) + .unwrap(); + assert!(recognize_scalar_or_array_union(&obj, &HashMap::new()).is_none()); + } + + #[test] + fn test_scalar_or_array_union_integer_type() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: integer + - type: array + items: + type: integer + "#, + ) + .unwrap(); + assert_eq!(recognize_scalar_or_array_union(&obj, &HashMap::new()), Some("integer")); + } + + #[test] + fn test_scalar_or_array_union_type_mismatch_returns_none() { + // oneOf [string, array] — scalar and array item types differ. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: array + items: + type: integer + "#, + ) + .unwrap(); + assert!(recognize_scalar_or_array_union(&obj, &HashMap::new()).is_none()); + } + + #[test] + fn test_scalar_or_array_union_with_ref() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r##" + oneOf: + - type: string + - $ref: "#/components/schemas/StringArray" + "##, + ) + .unwrap(); + let arr: OpenApiSchemaObject = + serde_yaml::from_str("type: array\nitems:\n type: string\n").unwrap(); + let mut components = HashMap::new(); + components.insert("StringArray".to_string(), arr); + assert_eq!(recognize_scalar_or_array_union(&obj, &components), Some("string")); + } + + #[test] + fn test_flatten_body_params_ref_chain_union() { + // End-to-end: a body property with $ref -> $ref -> oneOf [string, array] + // should produce a repeated string parameter. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + type: object + required: + - to + properties: + to: + $ref: "#/components/schemas/SendMessageTo" + "##, + ) + .unwrap(); + let send_msg_to: OpenApiSchemaObject = + serde_yaml::from_str(r##"$ref: "#/components/schemas/Addresses""##).unwrap(); + let addresses: OpenApiSchemaObject = serde_yaml::from_str( + r#" + oneOf: + - type: string + - type: array + items: + type: string + "#, + ) + .unwrap(); + let mut components = HashMap::new(); + components.insert("SendMessageTo".to_string(), send_msg_to); + components.insert("Addresses".to_string(), addresses); + let params = flatten_body_params_prefix(&schema, &components, 0, ""); + let to_param = params.get("to").expect("'to' parameter should exist"); + assert_eq!(to_param.param_type.as_deref(), Some("string")); + assert!(to_param.repeated, "'to' should be marked as repeated"); + assert!(to_param.required, "'to' should be required"); + assert!(!to_param.nullable, "'to' should not be nullable (no null branch)"); + } + + #[test] + fn test_flatten_body_params_nullable_string_array_union() { + // oneOf [string, array, null] → repeated + nullable + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + cc: + oneOf: + - type: string + - type: array + items: + type: string + - type: "null" + "#, + ) + .unwrap(); + let components = HashMap::new(); + let params = flatten_body_params_prefix(&schema, &components, 0, ""); + let cc_param = params.get("cc").expect("'cc' parameter should exist"); + assert_eq!(cc_param.param_type.as_deref(), Some("string")); + assert!(cc_param.repeated, "'cc' should be marked as repeated"); + assert!(cc_param.nullable, "'cc' should be nullable (has null branch)"); + } + + #[test] + fn test_flatten_promotes_nullable_union_field() { + // End-to-end: a body property with `anyOf: [string, null]` + // becomes a `nullable: true` scalar MethodParameter, not a + // typeless `VALUE` flag. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + authorId: + anyOf: + - type: string + - type: "null" + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let author = ¶ms["authorId"]; + assert!(author.nullable, "should be promoted to nullable: {author:?}"); + assert_eq!(author.param_type.as_deref(), Some("string")); + } + + #[test] + fn test_flatten_promotes_nullable_union_via_ref_to_scalar() { + // Same as above, but the non-null branch is `{$ref: ThreadIdString}` + // where ThreadIdString resolves to `type: string`. Recognizer + // follows the ref. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + type: object + properties: + threadId: + oneOf: + - $ref: "#/components/schemas/ThreadIdString" + - type: "null" + "##, + ) + .unwrap(); + let thread_id: OpenApiSchemaObject = serde_yaml::from_str("type: string\n").unwrap(); + let mut components = HashMap::new(); + components.insert("ThreadIdString".to_string(), thread_id); + let params = flatten_body_params(&schema, &components, 0); + let thread = ¶ms["threadId"]; + assert!(thread.nullable, "should be promoted: {thread:?}"); + assert_eq!(thread.param_type.as_deref(), Some("string")); + } + + // -- OpenAPI 3.0/3.1 examples ---------------------------------------- + + #[test] + fn test_example_30_single() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + example: "hello" + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!( + prop.example, + Some(serde_yaml::Value::String("hello".to_string())), + ); + assert!(prop.examples.is_none()); + } + + #[test] + fn test_examples_31_list() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + examples: + - "alpha" + - "beta" + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + let seq = prop.examples.as_ref().and_then(|v| v.as_sequence()).unwrap(); + assert_eq!(seq.len(), 2); + assert_eq!(seq[0], serde_yaml::Value::String("alpha".to_string())); + assert_eq!(seq[1], serde_yaml::Value::String("beta".to_string())); + assert!(prop.example.is_none()); + } + + #[test] + fn test_examples_lax_30_map_form() { + // BigCommerce-style schema-level `examples` map (out-of-spec for + // OpenAPI 3.0 at the schema level, but real-world specs use it). + // The parser must round-trip without erroring. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: array + examples: + Response: + value: + - red + - green + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + let map = prop.examples.as_ref().and_then(|v| v.as_mapping()).unwrap(); + assert!(map.contains_key(serde_yaml::Value::String("Response".to_string()))); + } + + // -- OpenAPI 3.0/3.1 numeric bounds ---------------------------------- + + #[test] + fn test_bounds_30_inclusive() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 0 + maximum: 100 + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, Some(0.0)); + assert_eq!(prop.maximum, Some(100.0)); + assert_eq!(prop.exclusive_minimum, None); + assert_eq!(prop.exclusive_maximum, None); + } + + #[test] + fn test_bounds_30_exclusive_flag_promotes_minimum() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 5 + exclusiveMinimum: true + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, None, "minimum becomes exclusive in 3.0 flag form"); + assert_eq!(prop.exclusive_minimum, Some(5.0)); + } + + #[test] + fn test_bounds_31_numeric_form() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + exclusiveMinimum: 5 + exclusiveMaximum: 99.5 + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, None); + assert_eq!(prop.exclusive_minimum, Some(5.0)); + assert_eq!(prop.exclusive_maximum, Some(99.5)); + } + + #[test] + fn test_bounds_30_and_31_produce_same_ir_for_strict_minimum() { + let obj_30: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 5 + exclusiveMinimum: true + "#, + ) + .unwrap(); + let obj_31: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + exclusiveMinimum: 5 + "#, + ) + .unwrap(); + let p30 = convert_schema_property(&obj_30); + let p31 = convert_schema_property(&obj_31); + assert_eq!(p30.minimum, p31.minimum); + assert_eq!(p30.exclusive_minimum, p31.exclusive_minimum); + } + + #[test] + fn test_bounds_30_exclusive_maximum_flag_promotes_maximum() { + // Symmetric to test_bounds_30_exclusive_flag_promotes_minimum — locks + // exclusiveMaximum's 3.0 boolean form against the same code path. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + maximum: 99 + exclusiveMaximum: true + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.maximum, None, "maximum becomes exclusive in 3.0 flag form"); + assert_eq!(prop.exclusive_maximum, Some(99.0)); + } + + #[test] + fn test_bounds_30_exclusive_false_keeps_inclusive() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + minimum: 5 + exclusiveMinimum: false + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.minimum, Some(5.0)); + assert_eq!(prop.exclusive_minimum, None); + } + + // -- OpenAPI 3.1 const ------------------------------------------------ + + #[test] + fn test_const_lowers_to_single_element_enum() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + const: webhook.user.created + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!( + prop.enum_values.as_deref(), + Some(&["webhook.user.created".to_string()][..]), + ); + } + + #[test] + fn test_const_numeric_value() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: integer + const: 42 + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!(prop.enum_values.as_deref(), Some(&["42".to_string()][..])); + } + + #[test] + fn test_const_lowered_through_flatten_body_params_inline() { + // Inline-property branch: `const` reaches the generated CLI flag as + // (a) a single-value enum constraint, (b) a client-side default + // that auto-injects on omission, and (c) optional even if the + // parent's required: list names it. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + required: [status] + properties: + status: + type: string + const: active + "#, + ) + .unwrap(); + let component_schemas = HashMap::new(); + let params = flatten_body_params(&schema, &component_schemas, 0); + let status = params.get("status").expect("status flag should be emitted"); + assert_eq!(status.enum_values.as_deref(), Some(&["active".to_string()][..])); + assert_eq!(status.default_value, Some(serde_json::Value::String("active".into()))); + assert!(!status.required, "const-bearing flag must be optional"); + } + + #[test] + fn test_const_lowered_through_flatten_body_params_via_ref() { + // $ref-resolution branch: same three properties hold when the const + // lives on a $ref-resolved component schema. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r##" + type: object + required: [role] + properties: + role: + $ref: "#/components/schemas/Role" + "##, + ) + .unwrap(); + let role_schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + const: admin + "#, + ) + .unwrap(); + let mut component_schemas = HashMap::new(); + component_schemas.insert("Role".to_string(), role_schema); + let params = flatten_body_params(&schema, &component_schemas, 0); + let role = params.get("role").expect("role flag should be emitted"); + assert_eq!(role.enum_values.as_deref(), Some(&["admin".to_string()][..])); + assert_eq!(role.default_value, Some(serde_json::Value::String("admin".into()))); + assert!(!role.required, "const-bearing $ref'd flag must be optional"); + } + + #[test] + fn test_const_numeric_default_keeps_wire_type() { + // A numeric const lands on the wire as a JSON number, not a string — + // critical for body fields whose const is meaningful as a literal + // type rather than a label. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + version: + type: integer + const: 2 + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let version = params.get("version").unwrap(); + assert_eq!( + version.default_value, + Some(serde_json::Value::Number(serde_json::Number::from(2))), + "numeric const must default to JSON number", + ); + } + + #[test] + fn test_const_does_not_override_explicit_enum() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + enum: [a, b] + const: c + "#, + ) + .unwrap(); + let prop = convert_schema_property(&obj); + assert_eq!( + prop.enum_values.as_deref(), + Some(&["a".to_string(), "b".to_string()][..]), + ); + } + + // -- OpenAPI 3.1 webhooks --------------------------------------------- + + #[test] + fn test_webhooks_block_parses_and_is_ignored_for_commands() { + let yaml = r##" +openapi: "3.1.0" +info: + title: Webhook-only spec + version: "1.0.0" +paths: {} +webhooks: + userCreated: + post: + operationId: handleUserCreated + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + "200": + description: OK +components: + schemas: + User: + type: object + properties: + id: { type: string } +"##; + let value: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + let desc = load_openapi_spec_from_value(value, "test-cli").expect("spec should parse"); + // Component schema is still reachable via discovery. + assert!(desc.schemas.contains_key("User")); + // No CLI methods generated. + let total_methods: usize = desc.resources.values().map(|r| r.methods.len()).sum(); + assert_eq!(total_methods, 0, "webhook ops must not become subcommands"); + } + + // -- OpenAPI 3.1 nullability ------------------------------------------ + + #[test] + fn test_nullable_30_explicit_field() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + nullable: true + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("string")); + assert!(obj.is_nullable()); + let prop = convert_schema_property(&obj); + assert!(prop.nullable); + assert_eq!(prop.prop_type.as_deref(), Some("string")); + } + + #[test] + fn test_nullable_31_type_array_with_null() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["string", "null"] + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("string")); + assert!(obj.is_nullable()); + let prop = convert_schema_property(&obj); + assert!(prop.nullable); + assert_eq!(prop.prop_type.as_deref(), Some("string")); + } + + #[test] + fn test_nullable_31_type_array_null_first() { + // Order shouldn't matter — `find` picks first non-null, presence of + // "null" anywhere flips nullability on. + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["null", "integer"] + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("integer")); + assert!(obj.is_nullable()); + } + + #[test] + fn test_nullable_31_type_array_only_null() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["null"] + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), None); + assert!(obj.is_nullable()); + } + + #[test] + fn test_nullable_30_regression_plain_type() { + let obj: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: string + "#, + ) + .unwrap(); + assert_eq!(obj.schema_type(), Some("string")); + assert!(!obj.is_nullable()); + let prop = convert_schema_property(&obj); + assert!(!prop.nullable); + } + + #[test] + fn test_nullable_at_parent_json_schema_level() { + // The parent JsonSchema (returned by convert_schema_object) carries + // its own nullable flag — covers the case where a top-level + // request/response body schema is itself nullable rather than just + // having nullable properties. + let obj_30: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + nullable: true + "#, + ) + .unwrap(); + let obj_31: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: ["object", "null"] + "#, + ) + .unwrap(); + let s_30 = convert_schema_object(&obj_30); + let s_31 = convert_schema_object(&obj_31); + assert!(s_30.nullable); + assert!(s_31.nullable); + assert_eq!(s_30.schema_type.as_deref(), Some("object")); + assert_eq!(s_31.schema_type.as_deref(), Some("object")); + } + + #[test] + fn test_flatten_body_params_marks_scalar_nullable_30() { + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + userId: + type: string + nullable: true + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let user_id = params.get("userId").expect("userId flag should be emitted"); + assert!(user_id.nullable, "scalar nullable (3.0) must propagate to MethodParameter"); + } + + #[test] + fn test_flatten_body_params_marks_scalar_nullable_31() { + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + userId: + type: ["string", "null"] + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let user_id = params.get("userId").expect("userId flag should be emitted"); + assert!(user_id.nullable, "scalar nullable (3.1) must propagate to MethodParameter"); + } + + #[test] + fn test_flatten_body_params_non_nullable_scalar_stays_false() { + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + userId: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + let user_id = params.get("userId").unwrap(); + assert!(!user_id.nullable); + } + + #[test] + fn test_flatten_body_params_nullable_integer_and_boolean_and_number() { + // Three scalar variants beyond string: all must propagate nullable. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + count: + type: ["integer", "null"] + active: + type: ["boolean", "null"] + ratio: + type: ["number", "null"] + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params["count"].nullable); + assert!(params["active"].nullable); + assert!(params["ratio"].nullable); + } + + #[test] + fn test_flatten_body_params_non_scalar_nullable_not_propagated() { + // type: [array, null] and type: [object, null] must NOT set + // param.nullable=true. Arrays + nullable would collide with + // ArgAction::Append; object-nullable has no parent flag to attach to. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + tags: + type: ["array", "null"] + items: + type: string + metadata: + type: ["object", "null"] + properties: + code: + type: string + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + // tags is an array → repeated, single flag, nullable must be false. + assert!(!params["tags"].nullable, "nullable array must not set param.nullable"); + // metadata is flattened to metadata.code; the only emitted flag is + // the inner scalar, which is non-nullable. + assert!(!params["metadata.code"].nullable); + } + + #[test] + fn test_flatten_body_params_nested_nullable_via_dot_notation() { + // metadata.code where code is nullable: dot-notation should preserve + // the nullable flag on the leaf. + let schema: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + metadata: + type: object + properties: + code: + type: ["string", "null"] + "#, + ) + .unwrap(); + let params = flatten_body_params(&schema, &HashMap::new(), 0); + assert!(params["metadata.code"].nullable); + } + + #[test] + fn test_nullable_schema_object_lowering() { + let obj_30: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + email: + type: string + nullable: true + "#, + ) + .unwrap(); + let obj_31: OpenApiSchemaObject = serde_yaml::from_str( + r#" + type: object + properties: + email: + type: ["string", "null"] + "#, + ) + .unwrap(); + let lowered_30 = convert_schema_object(&obj_30); + let lowered_31 = convert_schema_object(&obj_31); + assert_eq!(lowered_30.schema_type.as_deref(), Some("object")); + assert_eq!(lowered_31.schema_type.as_deref(), Some("object")); + assert!(lowered_30.properties["email"].nullable); + assert!(lowered_31.properties["email"].nullable); + assert_eq!( + lowered_30.properties["email"].prop_type.as_deref(), + Some("string"), + ); + assert_eq!( + lowered_31.properties["email"].prop_type.as_deref(), + Some("string"), + ); + } + + // ----------------------------------------------------------------------- + // Response-level $ref resolution helpers + // ----------------------------------------------------------------------- + + #[test] + fn test_status_code_sort_key_numeric() { + assert_eq!(status_code_sort_key("200"), Some(200)); + assert_eq!(status_code_sort_key("201"), Some(201)); + assert_eq!(status_code_sort_key("404"), Some(404)); + } + + #[test] + fn test_status_code_sort_key_wildcard() { + assert_eq!(status_code_sort_key("2XX"), Some(299)); + assert_eq!(status_code_sort_key("2xx"), Some(299)); + assert_eq!(status_code_sort_key("20X"), Some(209)); + } + + #[test] + fn test_status_code_sort_key_default_returns_none() { + assert_eq!(status_code_sort_key("default"), None); + } + + #[test] + fn test_resolve_response_ref_inline() { + let resp = OpenApiResponse { + content: Some(HashMap::new()), + }; + let r = OpenApiResponseOrRef::Inline(resp); + let components = HashMap::new(); + assert!(resolve_response_ref(&r, &components).is_some()); + } + + #[test] + fn test_resolve_response_ref_resolves_ref() { + let resp = OpenApiResponse { + content: Some(HashMap::new()), + }; + let mut components = HashMap::new(); + components.insert("Foo".to_string(), resp); + + let r = OpenApiResponseOrRef::Ref { + ref_path: "#/components/responses/Foo".to_string(), + }; + let resolved = resolve_response_ref(&r, &components); + assert!(resolved.is_some(), "should resolve $ref to component response"); + } + + #[test] + fn test_resolve_response_ref_missing_component() { + let r = OpenApiResponseOrRef::Ref { + ref_path: "#/components/responses/Missing".to_string(), + }; + let components = HashMap::new(); + assert!(resolve_response_ref(&r, &components).is_none()); + } + + #[test] + fn test_select_primary_response_picks_lowest_2xx() { + let resp_200 = OpenApiResponse { + content: Some(HashMap::new()), + }; + let resp_201 = OpenApiResponse { + content: Some(HashMap::new()), + }; + let mut responses = HashMap::new(); + responses.insert( + "201".to_string(), + OpenApiResponseOrRef::Inline(resp_201), + ); + responses.insert( + "200".to_string(), + OpenApiResponseOrRef::Inline(resp_200), + ); + let components = HashMap::new(); + let result = select_primary_response(&responses, &components); + assert!(result.is_some(), "should select a 2xx response"); + } + + #[test] + fn test_select_primary_response_falls_back_to_default() { + let resp = OpenApiResponse { + content: Some(HashMap::new()), + }; + let mut responses = HashMap::new(); + responses.insert( + "default".to_string(), + OpenApiResponseOrRef::Inline(resp), + ); + let components = HashMap::new(); + let result = select_primary_response(&responses, &components); + assert!(result.is_some(), "should fall back to 'default'"); + } + + #[test] + fn test_extract_response_with_ref_schema() { + let mut schemas = HashMap::new(); + let mut component_responses = HashMap::new(); + + let mut content = HashMap::new(); + content.insert( + "application/json".to_string(), + OpenApiMediaType { + schema: Some(OpenApiSchemaObject { + schema_ref: Some("#/components/schemas/Widget".to_string()), + ..Default::default() + }), + encoding: HashMap::new(), + }, + ); + component_responses.insert( + "WidgetResp".to_string(), + OpenApiResponse { + content: Some(content), + }, + ); + + let mut responses = HashMap::new(); + responses.insert( + "200".to_string(), + OpenApiResponseOrRef::Ref { + ref_path: "#/components/responses/WidgetResp".to_string(), + }, + ); + + let result = extract_response(&responses, "test_op", &mut schemas, &component_responses); + assert!(result.is_some(), "should extract response schema from $ref'd response"); + let sr = result.unwrap(); + assert_eq!(sr.schema_ref.as_deref(), Some("Widget")); + } + + #[test] + fn test_extract_response_inline_schema() { + let mut schemas = HashMap::new(); + let component_responses = HashMap::new(); + + let mut content = HashMap::new(); + content.insert( + "application/json".to_string(), + OpenApiMediaType { + schema: Some(OpenApiSchemaObject { + type_field: TypeField { schema_type: Some("object".to_string()), null_in_array: false }, + ..Default::default() + }), + encoding: HashMap::new(), + }, + ); + + let mut responses = HashMap::new(); + responses.insert( + "200".to_string(), + OpenApiResponseOrRef::Inline(OpenApiResponse { + content: Some(content), + }), + ); + + let result = extract_response(&responses, "inline_op", &mut schemas, &component_responses); + assert!(result.is_some(), "should extract inline response schema"); + let sr = result.unwrap(); + assert_eq!( + sr.schema_ref.as_deref(), + Some("inline_op_response"), + "inline schema should be registered under synthetic name", + ); + assert!( + schemas.contains_key("inline_op_response"), + "inline schema must be stored in schemas map", + ); + } + + // ----------------------------------------------------------------------- + // Property-value-as-array tolerance (ElevenLabs / Fern-processed specs) + // ----------------------------------------------------------------------- + + #[test] + fn property_value_single_element_array_unwrapped() { + // Some Fern-processed specs emit a property value as a + // single-element array wrapping the real schema object. + // The parser should unwrap it transparently. + let yaml = r#" +openapi: "3.1.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /items: + get: + operationId: listItems + x-fern-sdk-method-name: list + x-fern-sdk-group-name: items + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + normal_prop: + type: string + wrapped_prop: + - type: integer + description: "wrapped in array" +"#; + let doc = load_openapi_spec(yaml, "t") + .expect("spec with array-valued property should parse"); + let items = doc.resources.get("items").expect("items resource"); + assert!( + items.methods.values().any(|m| m.id.as_deref() == Some("listItems")), + "listItems method should exist", + ); + } + + #[test] + fn property_value_multi_element_array_defaults() { + // Multi-element arrays at property positions fall back to an + // empty (default) schema instead of aborting the entire parse. + let yaml = r#" +openapi: "3.1.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /things: + get: + operationId: getThings + x-fern-sdk-method-name: get + x-fern-sdk-group-name: things + responses: + "200": + description: ok + content: + application/json: + schema: + type: object + properties: + ok_field: + type: string + odd_field: + - type: string + - type: integer +"#; + let doc = load_openapi_spec(yaml, "t") + .expect("spec with multi-element array property should parse"); + let things = doc.resources.get("things").expect("things resource"); + assert!( + things.methods.values().any(|m| m.id.as_deref() == Some("getThings")), + "getThings method should exist", + ); + } + + #[test] + fn component_schema_as_array_tolerated() { + // A component schema whose value is a single-element array + // should be unwrapped, not crash the parser. + let yaml = r##" +openapi: "3.1.0" +info: + title: Test + version: "1.0" +servers: + - url: https://api.example.com +paths: + /foo: + get: + operationId: getFoo + x-fern-sdk-method-name: get + x-fern-sdk-group-name: foo + responses: + "200": + description: ok + content: + application/json: + schema: + $ref: "#/components/schemas/Normal" +components: + schemas: + Normal: + type: object + properties: + name: + type: string + Wrapped: + - type: object + properties: + state: + type: string +"##; + let doc = load_openapi_spec(yaml, "t") + .expect("spec with array-valued component schema should parse"); + assert!( + doc.schemas.contains_key("Normal"), + "Normal schema should be present", + ); + } +} diff --git a/src/openapi/skill_emitter.rs b/src/openapi/skill_emitter.rs new file mode 100644 index 0000000..537f8b4 --- /dev/null +++ b/src/openapi/skill_emitter.rs @@ -0,0 +1,761 @@ +//! Deterministic SKILL.md generator for OpenAPI-driven CLIs. +//! +//! Walks the parsed [`RestDescription`] and emits one markdown file per +//! top-level command group plus a shared file containing auth setup and +//! global flags. All output is fully deterministic — pure Rust string +//! templates over spec data, no LLM, no hand-written overlay files. +//! +//! Public surface: [`generate_skills`] — a pure function returning +//! `(PathBuf, String)` pairs. The caller is responsible for filesystem +//! writes. + +use std::fmt::Write as FmtWrite; +use std::path::PathBuf; + +use clap::{Arg, Command}; + +use crate::auth::{AuthCredentialSource, SchemeBinding}; +use crate::openapi::discovery::{RestDescription, RestResource, SecurityScheme}; +use crate::text; + +/// Maximum characters for the frontmatter `description` field. +const FRONTMATTER_DESC_LIMIT: usize = 120; + +/// Returns the clap `Command` for `generate-skills` so it appears in +/// `--help`, shell completions, and man pages. +pub fn generate_skills_command() -> Command { + Command::new("generate-skills") + .about("Generate SKILL.md files for AI agent integration") + .arg( + Arg::new("output-dir") + .long("output-dir") + .value_name("PATH") + .help("Output directory [default: skills]"), + ) +} + +/// Generates all SKILL.md files for the given binary. +/// +/// Returns a list of `(relative_path, content)` pairs. The caller writes +/// them under whatever output directory was requested. +pub fn generate_skills( + doc: &RestDescription, + bin_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) -> Vec<(PathBuf, String)> { + let mut files: Vec<(PathBuf, String)> = Vec::new(); + + // Shared skill + let shared_path = PathBuf::from(format!("{bin_name}-shared")).join("SKILL.md"); + let shared_content = render_shared_skill(doc, bin_name, auth_bindings); + files.push((shared_path, shared_content)); + + // Per-group skills — sorted for deterministic output + let mut group_names: Vec<&String> = doc.resources.keys().collect(); + group_names.sort(); + for group_name in group_names { + let resource = &doc.resources[group_name]; + let group_path = PathBuf::from(format!("{bin_name}-{group_name}")).join("SKILL.md"); + let group_content = render_group_skill(doc, bin_name, group_name, resource); + files.push((group_path, group_content)); + } + + files +} + +// --------------------------------------------------------------------------- +// Shared skill +// --------------------------------------------------------------------------- + +fn render_shared_skill( + doc: &RestDescription, + bin_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) -> String { + let mut out = String::new(); + + // Frontmatter + let desc = format!( + "{bin_name} CLI: Shared patterns for authentication, global flags, and output formatting." + ); + write_frontmatter(&mut out, &format!("{bin_name}-shared"), &desc); + + // Title + let _ = writeln!(out, "# {bin_name} — Shared Reference\n"); + + // Auth section + let _ = writeln!(out, "## Authentication\n"); + if auth_bindings.is_empty() && doc.security_schemes.is_empty() { + let _ = writeln!(out, "No authentication configured.\n"); + } else { + render_auth_section(&mut out, doc, bin_name, auth_bindings); + } + + // Global + commonly-used flags. + // + // This table groups every flag an agent is likely to need into one + // place — both the *harness* globals (available on every op: + // `--schema`, `--dry-run`, `--format`, `--base-url`, `--quiet`) and + // *per-op* affordances added only when the spec supports them + // (`--page-all`, `--output`, `--params`, `--json`, etc.). The JSON + // `--schema` flag distinguishes the two via `globalFlags` vs + // per-op capability hints (`paginable`, `binaryResponse`); this + // SKILL.md table is for the human reader who just wants the + // affordance list. See ADR-0006 for the JSON contract. + let _ = writeln!(out, "## Global Flags\n"); + let _ = writeln!( + out, + "These flags appear across the CLI. The harness-level ones (`--dry-run`, \ + `--format`, `--base-url`, `--quiet`) are available on every command; the \ + rest (`--page-all`, `--output`, ...) surface on operations whose spec \ + supports the affordance — check the per-op `--schema` output's \ + `paginable` / `binaryResponse` hints to know which ops carry them.\n" + ); + let _ = writeln!(out, "| Flag | Description | Default |"); + let _ = writeln!(out, "|------|-------------|---------|"); + let _ = writeln!( + out, + "| `--dry-run` | Validate locally without sending the request | |" + ); + let _ = writeln!( + out, + "| `--format ` | Output format: `json`, `table`, `yaml`, `csv`, `raw`, `jsonl`, `http` | `json` |" + ); + let _ = writeln!( + out, + "| `--base-url ` | Override the API base URL | |" + ); + let _ = writeln!( + out, + "| `--params ` | URL/query/path parameters as JSON | |" + ); + let _ = writeln!( + out, + "| `--json ` | Request body for POST/PATCH/PUT | |" + ); + let _ = writeln!( + out, + "| `-o, --output ` | Write binary responses to a file; use `-` to stream to stdout for piping into other commands (e.g. `ffplay -`, `aplay -`). | |" + ); + let _ = writeln!( + out, + "| `--page-all` | Auto-paginate (NDJSON) | off |" + ); + let _ = writeln!( + out, + "| `--page-limit ` | Max pages to fetch | `10` |" + ); + let _ = writeln!( + out, + "| `--page-delay ` | Delay between page fetches | `100` |" + ); + let _ = writeln!( + out, + "| `--no-pager` | Disable pager even on interactive terminals | |" + ); + let _ = writeln!( + out, + "| `--no-retry` | Disable retries | |" + ); + let _ = writeln!( + out, + "| `--no-extract` | Print the full response body | |" + ); + let _ = writeln!(out); + + // Output formatting tips + let _ = writeln!(out, "## Output Formatting\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# JSON (default)"); + let _ = writeln!(out, "{bin_name} --format json\n"); + let _ = writeln!(out, "# Table view"); + let _ = writeln!(out, "{bin_name} --format table\n"); + let _ = writeln!(out, "# Pipe-friendly: jq, grep, etc."); + let _ = writeln!( + out, + "{bin_name} | jq '.fieldName'" + ); + let _ = writeln!(out, "```\n"); + + // Dry-run section + let _ = writeln!(out, "## Dry Run\n"); + let _ = writeln!( + out, + "Use `--dry-run` to preview the HTTP request without sending it:\n" + ); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "{bin_name} --dry-run"); + let _ = writeln!(out, "```\n"); + + out +} + +fn render_auth_section( + out: &mut String, + doc: &RestDescription, + bin_name: &str, + auth_bindings: &[(String, SchemeBinding)], +) { + if !auth_bindings.is_empty() { + for (scheme_name, binding) in auth_bindings { + let scheme_type = doc + .security_schemes + .get(scheme_name) + .map(describe_scheme_type) + .unwrap_or_else(|| "bearer".to_string()); + + let source_desc = describe_binding_source(binding); + let _ = writeln!( + out, + "- **{scheme_name}** ({scheme_type}): {source_desc}" + ); + } + let _ = writeln!(out); + + // Emit setup instructions based on binding sources + let env_vars = collect_env_vars(auth_bindings); + if !env_vars.is_empty() { + let _ = writeln!(out, "Set the required environment variable(s):\n"); + let _ = writeln!(out, "```bash"); + for var in &env_vars { + let _ = writeln!(out, "export {var}=\"\""); + } + let _ = writeln!(out, "```\n"); + + let _ = writeln!(out, "Verify authentication works:\n"); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "{bin_name} --help"); + let _ = writeln!(out, "```\n"); + } + } else { + // Fall back to security schemes from spec + let mut schemes: Vec<(&String, &SecurityScheme)> = doc.security_schemes.iter().collect(); + schemes.sort_by_key(|(name, _)| *name); + for (name, scheme) in &schemes { + let _ = writeln!(out, "- **{name}** ({})", describe_scheme_type(scheme)); + } + let _ = writeln!(out); + } +} + +fn describe_scheme_type(scheme: &SecurityScheme) -> String { + match scheme { + SecurityScheme::HttpBearer => "bearer token".to_string(), + SecurityScheme::HttpBasic => "HTTP basic auth".to_string(), + SecurityScheme::ApiKeyHeader { name } => format!("API key in `{name}` header"), + SecurityScheme::ApiKeyQuery { name } => format!("API key in `{name}` query param"), + SecurityScheme::OAuth2 => "OAuth2 bearer token".to_string(), + SecurityScheme::Other(ty) => ty.clone(), + } +} + +fn describe_binding_source(binding: &SchemeBinding) -> String { + match binding { + SchemeBinding::Token(src) => describe_credential_source(src), + SchemeBinding::Basic { username, password } => { + format!( + "HTTP basic — username: {}, password: {}", + describe_credential_source(username), + describe_credential_source(password), + ) + } + SchemeBinding::Custom(_) => "custom auth provider".to_string(), + } +} + +fn describe_credential_source(src: &AuthCredentialSource) -> String { + match src { + AuthCredentialSource::Env(name) => format!("`{name}` env var"), + AuthCredentialSource::Cli(arg) => format!("`--{arg}` flag"), + AuthCredentialSource::File(path) => format!("`{}` file", path.display()), + AuthCredentialSource::Literal(_) => "built-in literal".to_string(), + AuthCredentialSource::Closure(_, Some(hint)) => hint.clone(), + AuthCredentialSource::Closure(_, None) => "custom resolver".to_string(), + AuthCredentialSource::Chain(sources) => sources + .iter() + .map(describe_credential_source) + .collect::>() + .join(" or "), + AuthCredentialSource::Keyring { service, account } => { + format!("keyring `{service}:{account}` (populated by `auth login`)") + } + AuthCredentialSource::Missing => "(unbound)".to_string(), + } +} + +fn collect_env_vars(bindings: &[(String, SchemeBinding)]) -> Vec { + let mut vars = Vec::new(); + for (_, binding) in bindings { + collect_env_vars_from_binding(binding, &mut vars); + } + vars +} + +fn collect_env_vars_from_binding(binding: &SchemeBinding, out: &mut Vec) { + match binding { + SchemeBinding::Token(src) => collect_env_vars_from_source(src, out), + SchemeBinding::Basic { username, password } => { + collect_env_vars_from_source(username, out); + collect_env_vars_from_source(password, out); + } + SchemeBinding::Custom(_) => {} + } +} + +fn collect_env_vars_from_source(src: &AuthCredentialSource, out: &mut Vec) { + match src { + AuthCredentialSource::Env(name) if !out.contains(name) => { + out.push(name.clone()); + } + AuthCredentialSource::Chain(sources) => { + for s in sources { + collect_env_vars_from_source(s, out); + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Per-group skill +// --------------------------------------------------------------------------- + +fn render_group_skill( + doc: &RestDescription, + bin_name: &str, + group_name: &str, + resource: &RestResource, +) -> String { + let mut out = String::new(); + + // Frontmatter + let skill_name = format!("{bin_name}-{group_name}"); + let group_desc = group_description(doc, group_name); + let frontmatter_desc = text::truncate_description(&group_desc, FRONTMATTER_DESC_LIMIT, true); + write_frontmatter(&mut out, &skill_name, &frontmatter_desc); + + // Title + let _ = writeln!(out, "# {group_name}\n"); + + // Prerequisite + let _ = writeln!( + out, + "> **PREREQUISITE:** Read `../{bin_name}-shared/SKILL.md` for auth, \ + global flags, and output formatting. If missing, run \ + `{bin_name} generate-skills` to create it.\n" + ); + + // Syntax + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "{bin_name} {group_name} [flags]"); + let _ = writeln!(out, "```\n"); + + // API Resources tree + let _ = writeln!(out, "## API Resources\n"); + render_resource_tree(&mut out, resource, 0); + + // Discovering Commands + let _ = writeln!(out, "## Discovering Commands\n"); + let _ = writeln!( + out, + "**Agents: always prefer `--schema` over `--help`** — `--schema` returns \ + JSON; `--help` returns human prose.\n" + ); + let _ = writeln!(out, "```bash"); + let _ = writeln!(out, "# Machine-readable surface (use this)"); + let _ = writeln!(out, "{bin_name} {group_name} --schema"); + let _ = writeln!(out, "{bin_name} {group_name} --schema\n"); + let _ = writeln!(out, "# Human-readable help (for humans)"); + let _ = writeln!(out, "{bin_name} {group_name} --help"); + let _ = writeln!(out, "```\n"); + + out +} + +fn group_description(doc: &RestDescription, group_name: &str) -> String { + // Try x-fern-groups metadata first + if let Some(info) = doc.groups.get(group_name) { + if let Some(ref summary) = info.summary { + return summary.clone(); + } + if let Some(ref description) = info.description { + return first_sentence(description); + } + } + + // Fall back to spec title/description + if let Some(ref title) = doc.title { + return format!("{title}: Operations on {group_name}"); + } + format!("Operations on {group_name}") +} + +fn first_sentence(s: &str) -> String { + if let Some(idx) = s.find(". ") { + s[..=idx].to_string() + } else { + s.to_string() + } +} + +fn render_resource_tree(out: &mut String, resource: &RestResource, depth: usize) { + // Render methods at this level — sorted + let mut method_names: Vec<&String> = resource.methods.keys().collect(); + method_names.sort(); + for method_name in method_names { + let method = &resource.methods[method_name]; + let desc = method + .description + .as_deref() + .map(|d| text::truncate_description(d, text::CLI_DESCRIPTION_LIMIT, false)) + .unwrap_or_default(); + if desc.is_empty() { + let _ = writeln!(out, " - `{method_name}`"); + } else { + let _ = writeln!(out, " - `{method_name}` — {desc}"); + } + } + + // Render sub-resources — sorted, with heading + let mut sub_names: Vec<&String> = resource.resources.keys().collect(); + sub_names.sort(); + for sub_name in sub_names { + let sub = &resource.resources[sub_name]; + let heading_level = "#".repeat((3 + depth).min(6)); + let _ = writeln!(out, "\n{heading_level} {sub_name}\n"); + render_resource_tree(out, sub, depth + 1); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn write_frontmatter(out: &mut String, name: &str, description: &str) { + let _ = writeln!(out, "---"); + let _ = writeln!(out, "name: \"{}\"", escape_yaml_string(name)); + let _ = writeln!(out, "description: \"{}\"", escape_yaml_string(description)); + let _ = writeln!(out, "---\n"); +} + +fn escape_yaml_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +/// Placeholder value for a method parameter, derived from format or type. +pub fn example_placeholder(param: &crate::openapi::discovery::MethodParameter) -> String { + // Check format first + if let Some(ref fmt) = param.format { + match fmt.as_str() { + "email" => return "user@example.com".to_string(), + "uri" | "url" => return "https://example.com".to_string(), + "uuid" => return "".to_string(), + "date" => return "2024-01-01".to_string(), + "date-time" => return "2024-01-01T00:00:00Z".to_string(), + "int32" | "int64" => return "42".to_string(), + "float" | "double" => return "3.14".to_string(), + _ => {} + } + } + + // Fall back to type + match param.param_type.as_deref() { + Some("integer") => "42".to_string(), + Some("number") => "3.14".to_string(), + Some("boolean") => "true".to_string(), + Some("array") => "[]".to_string(), + Some("object") => "{}".to_string(), + _ => "".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + use crate::openapi::discovery::{MethodParameter, RestDescription, RestMethod, RestResource}; + + fn minimal_doc() -> RestDescription { + let mut resources = HashMap::new(); + let mut methods = HashMap::new(); + methods.insert( + "list".to_string(), + RestMethod { + description: Some("List all items.".to_string()), + http_method: "GET".to_string(), + path: "/items".to_string(), + ..Default::default() + }, + ); + methods.insert( + "get".to_string(), + RestMethod { + description: Some("Get a single item by ID.".to_string()), + http_method: "GET".to_string(), + path: "/items/{id}".to_string(), + ..Default::default() + }, + ); + resources.insert( + "items".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + RestDescription { + name: "test-api".to_string(), + title: Some("Test API".to_string()), + resources, + ..Default::default() + } + } + + fn bindings_for(env_var: &str) -> Vec<(String, SchemeBinding)> { + vec![( + "bearerAuth".to_string(), + SchemeBinding::Token(AuthCredentialSource::Env(env_var.to_string())), + )] + } + + #[test] + fn generates_shared_and_group_files() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let names: Vec = files.iter().map(|(p, _)| p.display().to_string()).collect(); + assert!(names.contains(&"testcli-shared/SKILL.md".to_string())); + assert!(names.contains(&"testcli-items/SKILL.md".to_string())); + assert_eq!(files.len(), 2); + } + + #[test] + fn shared_skill_has_valid_frontmatter() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let shared = &files[0].1; + assert!(shared.starts_with("---\n")); + assert!(shared.contains("name: \"testcli-shared\"")); + assert!(shared.contains("description: \"")); + // Verify closing frontmatter + let second_fence = shared[4..].find("---").unwrap() + 4; + assert!(second_fence > 4); + } + + #[test] + fn group_skill_has_valid_frontmatter() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let group = &files[1].1; + assert!(group.starts_with("---\n")); + assert!(group.contains("name: \"testcli-items\"")); + assert!(group.contains("description: \"")); + } + + #[test] + fn shared_skill_contains_auth_section() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &bindings_for("TEST_API_KEY")); + let shared = &files[0].1; + assert!(shared.contains("## Authentication")); + assert!(shared.contains("TEST_API_KEY")); + assert!(shared.contains("bearerAuth")); + } + + #[test] + fn shared_skill_contains_global_flags() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let shared = &files[0].1; + assert!(shared.contains("## Global Flags")); + assert!(shared.contains("--dry-run")); + assert!(shared.contains("--format")); + assert!(shared.contains("--page-all")); + } + + #[test] + fn group_skill_lists_methods() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let group = &files[1].1; + assert!(group.contains("`get`")); + assert!(group.contains("`list`")); + assert!(group.contains("List all items.")); + } + + #[test] + fn group_skill_has_prerequisite_link() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let group = &files[1].1; + assert!(group.contains("testcli-shared/SKILL.md")); + assert!(group.contains("testcli generate-skills")); + } + + #[test] + fn group_skill_has_discovering_commands() { + let doc = minimal_doc(); + let files = generate_skills(&doc, "testcli", &[]); + let group = &files[1].1; + assert!(group.contains("## Discovering Commands")); + assert!(group.contains("testcli items --help")); + assert!(group.contains("testcli items --schema")); + } + + #[test] + fn example_placeholder_format_driven() { + let email_param = MethodParameter { + format: Some("email".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&email_param), "user@example.com"); + + let uuid_param = MethodParameter { + format: Some("uuid".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&uuid_param), ""); + + let int_param = MethodParameter { + format: Some("int64".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&int_param), "42"); + } + + #[test] + fn example_placeholder_type_driven() { + let int_param = MethodParameter { + param_type: Some("integer".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&int_param), "42"); + + let bool_param = MethodParameter { + param_type: Some("boolean".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&bool_param), "true"); + + let string_param = MethodParameter { + param_type: Some("string".to_string()), + ..Default::default() + }; + assert_eq!(example_placeholder(&string_param), ""); + } + + #[test] + fn example_placeholder_missing_fields() { + let empty = MethodParameter::default(); + assert_eq!(example_placeholder(&empty), ""); + } + + #[test] + fn multi_level_resource_nesting() { + let mut inner_methods = HashMap::new(); + inner_methods.insert( + "read".to_string(), + RestMethod { + description: Some("Read nested item.".to_string()), + ..Default::default() + }, + ); + + let mut sub_resources = HashMap::new(); + sub_resources.insert( + "nested".to_string(), + RestResource { + methods: inner_methods, + resources: HashMap::new(), + }, + ); + + let mut top_methods = HashMap::new(); + top_methods.insert( + "list".to_string(), + RestMethod { + description: Some("List things.".to_string()), + ..Default::default() + }, + ); + + let mut resources = HashMap::new(); + resources.insert( + "things".to_string(), + RestResource { + methods: top_methods, + resources: sub_resources, + }, + ); + + let doc = RestDescription { + name: "api".to_string(), + resources, + ..Default::default() + }; + + let files = generate_skills(&doc, "cli", &[]); + let group = &files[1].1; + assert!(group.contains("`list`")); + assert!(group.contains("### nested")); + assert!(group.contains("`read`")); + } + + #[test] + fn empty_resources_produces_only_shared() { + let doc = RestDescription { + name: "empty".to_string(), + ..Default::default() + }; + let files = generate_skills(&doc, "empty", &[]); + assert_eq!(files.len(), 1); + assert!(files[0].0.display().to_string().contains("shared")); + } + + #[test] + fn deterministic_output_across_calls() { + let doc = minimal_doc(); + let bindings = bindings_for("KEY"); + let a = generate_skills(&doc, "test", &bindings); + let b = generate_skills(&doc, "test", &bindings); + assert_eq!(a.len(), b.len()); + for (fa, fb) in a.iter().zip(b.iter()) { + assert_eq!(fa.0, fb.0); + assert_eq!(fa.1, fb.1); + } + } + + #[test] + fn frontmatter_description_escapes_quotes() { + let mut resources = HashMap::new(); + let mut methods = HashMap::new(); + methods.insert( + "get".to_string(), + RestMethod::default(), + ); + resources.insert( + "test".to_string(), + RestResource { + methods, + resources: HashMap::new(), + }, + ); + + let doc = RestDescription { + name: "api".to_string(), + title: Some("API with \"quotes\"".to_string()), + resources, + ..Default::default() + }; + let files = generate_skills(&doc, "cli", &[]); + let group = &files[1].1; + assert!(group.contains("\\\"quotes\\\"")); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..6ae0f1b --- /dev/null +++ b/src/output.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared output helpers for terminal sanitization, coloring, and stderr +//! messaging. +//! +//! Every function that prints untrusted content to the terminal should use +//! these helpers to prevent escape-sequence injection, Unicode spoofing, +//! and to respect `NO_COLOR` / non-TTY environments. + +use crate::error::CliError; + +// ── Dangerous character detection ───────────────────────────────────── + +/// Returns `true` for Unicode characters that are dangerous in terminal +/// output but not caught by `char::is_control()`: zero-width chars, bidi +/// overrides, Unicode line/paragraph separators, and directional isolates. +/// +/// Using `matches!` with char ranges gives O(1) per character instead of the +/// O(M) linear scan that a slice `.contains()` would require. +pub(crate) fn is_dangerous_unicode(c: char) -> bool { + matches!(c, + // zero-width: ZWSP, ZWNJ, ZWJ, BOM/ZWNBSP + '\u{200B}'..='\u{200D}' | '\u{FEFF}' | + // bidi: LRE, RLE, PDF, LRO, RLO + '\u{202A}'..='\u{202E}' | + // line / paragraph separators + '\u{2028}'..='\u{2029}' | + // directional isolates: LRI, RLI, FSI, PDI + '\u{2066}'..='\u{2069}' + ) +} + +// ── Sanitization ────────────────────────────────────────────────────── + +/// Strip dangerous characters from untrusted text before printing to the +/// terminal. Removes ASCII control characters (except `\n` and `\t`, +/// which are preserved for readability) and dangerous Unicode characters +/// (bidi overrides, zero-width chars, line/paragraph separators). +pub(crate) fn sanitize_for_terminal(text: &str) -> String { + text.chars() + .filter(|&c| { + if c == '\n' || c == '\t' { + return true; + } + if c.is_control() { + return false; + } + !is_dangerous_unicode(c) + }) + .collect() +} + +/// Rejects strings containing control characters (C0: U+0000–U+001F, +/// C1: U+0080–U+009F, and DEL: U+007F) or dangerous Unicode characters +/// such as zero-width chars, bidi overrides, and line/paragraph separators. +/// +/// Used for validating CLI argument values at the parse boundary. +pub(crate) fn reject_dangerous_chars(value: &str, flag_name: &str) -> Result<(), CliError> { + for c in value.chars() { + if c.is_control() { + return Err(CliError::Validation(format!( + "{flag_name} contains invalid control characters" + ))); + } + if is_dangerous_unicode(c) { + return Err(CliError::Validation(format!( + "{flag_name} contains invalid Unicode characters" + ))); + } + } + Ok(()) +} + +// ── Color ───────────────────────────────────────────────────────────── + +/// Returns true when stderr is connected to an interactive terminal and +/// `NO_COLOR` is not set, meaning ANSI color codes will be visible. +pub(crate) fn stderr_supports_color() -> bool { + use std::io::IsTerminal; + std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() +} + +/// Wrap `text` in ANSI bold + the given color code, resetting afterwards. +/// Returns the plain text unchanged when stderr is not a TTY or `NO_COLOR` +/// is set. +pub(crate) fn colorize(text: &str, ansi_color: &str) -> String { + if stderr_supports_color() && ansi_color.chars().all(|c| c.is_ascii_digit()) { + format!("\x1b[1;{ansi_color}m{text}\x1b[0m") + } else { + text.to_string() + } +} + + +#[cfg(test)] +mod tests { + use super::*; + + // ── sanitize_for_terminal ───────────────────────────────────── + + #[test] + fn sanitize_strips_ansi_escape_sequences() { + let input = "normal \x1b[31mred text\x1b[0m end"; + let sanitized = sanitize_for_terminal(input); + assert_eq!(sanitized, "normal [31mred text[0m end"); + assert!(!sanitized.contains('\x1b')); + } + + #[test] + fn sanitize_preserves_newlines_and_tabs() { + let input = "line1\nline2\ttab"; + assert_eq!(sanitize_for_terminal(input), "line1\nline2\ttab"); + } + + #[test] + fn sanitize_strips_bell_and_backspace() { + let input = "hello\x07bell\x08backspace"; + assert_eq!(sanitize_for_terminal(input), "hellobellbackspace"); + } + + #[test] + fn sanitize_strips_carriage_return() { + let input = "real\rfake"; + assert_eq!(sanitize_for_terminal(input), "realfake"); + } + + #[test] + fn sanitize_strips_bidi_overrides() { + let input = "hello\u{202E}dlrow"; + assert_eq!(sanitize_for_terminal(input), "hellodlrow"); + } + + #[test] + fn sanitize_strips_zero_width_chars() { + assert_eq!(sanitize_for_terminal("foo\u{200B}bar"), "foobar"); + assert_eq!(sanitize_for_terminal("foo\u{FEFF}bar"), "foobar"); + } + + #[test] + fn sanitize_strips_line_separators() { + assert_eq!(sanitize_for_terminal("line1\u{2028}line2"), "line1line2"); + assert_eq!(sanitize_for_terminal("para1\u{2029}para2"), "para1para2"); + } + + #[test] + fn sanitize_strips_directional_isolates() { + assert_eq!(sanitize_for_terminal("a\u{2066}b\u{2069}c"), "abc"); + } + + #[test] + fn sanitize_preserves_normal_unicode() { + assert_eq!(sanitize_for_terminal("日本語 café αβγ"), "日本語 café αβγ"); + } + + // ── reject_dangerous_chars ──────────────────────────────────── + + #[test] + fn reject_clean_string() { + assert!(reject_dangerous_chars("hello/world", "test").is_ok()); + } + + #[test] + fn reject_tab() { + assert!(reject_dangerous_chars("hello\tworld", "test").is_err()); + } + + #[test] + fn reject_newline() { + assert!(reject_dangerous_chars("hello\nworld", "test").is_err()); + } + + #[test] + fn reject_del() { + assert!(reject_dangerous_chars("hello\x7Fworld", "test").is_err()); + } + + #[test] + fn reject_zero_width_space() { + assert!(reject_dangerous_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn reject_bom() { + assert!(reject_dangerous_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn reject_rtl_override() { + assert!(reject_dangerous_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn reject_line_separator() { + assert!(reject_dangerous_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn reject_paragraph_separator() { + assert!(reject_dangerous_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn reject_zero_width_joiner() { + assert!(reject_dangerous_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn reject_preserves_normal_unicode() { + assert!(reject_dangerous_chars("日本語", "test").is_ok()); + assert!(reject_dangerous_chars("café", "test").is_ok()); + assert!(reject_dangerous_chars("αβγ", "test").is_ok()); + } + + #[test] + fn reject_c1_control_csi() { + // U+009B is the C1 "Control Sequence Introducer" — can inject + // terminal escape sequences just like ESC+[ + assert!(reject_dangerous_chars("foo\u{009B}bar", "test").is_err()); + } + + // ── colorize ────────────────────────────────────────────────── + + #[test] + fn colorize_returns_text_in_no_color_mode() { + // In test environment, stderr is typically not a TTY + let result = colorize("hello", "31"); + // Either plain text (no TTY) or colored (TTY) — we just verify + // it contains the original text + assert!(result.contains("hello")); + } +} diff --git a/src/pager.rs b/src/pager.rs new file mode 100644 index 0000000..9166f89 --- /dev/null +++ b/src/pager.rs @@ -0,0 +1,328 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! External pager support for paginated CLI output. +//! +//! When `--page-all` is used on an interactive terminal, output is piped +//! through an external pager (`$PAGER`, defaulting to `less`). This gives +//! users scrollable, searchable output instead of a wall of text. +//! +//! The pager is **never** spawned when stdout is not a TTY (piped or +//! redirected) or when `--no-pager` is passed. + +use std::io::{self, IsTerminal, Write}; +use std::process::{Child, Command, Stdio}; + +/// Resolved pager configuration. +/// +/// Built once per command invocation from CLI flags and environment. +#[derive(Debug)] +pub struct PagerConfig { + /// The pager program to run (resolved from env vars). + pub program: String, + /// Whether the pager is disabled via `--no-pager`. + pub disabled: bool, +} + +impl PagerConfig { + /// Resolve pager configuration from the environment. + /// + /// Precedence: `$_PAGER` → `$PAGER` → platform default. + /// The platform default is `less` on Unix, `more` on Windows. + pub fn from_env(binary_name: &str) -> Self { + let prefix = binary_name.to_uppercase().replace('-', "_"); + let binary_pager_var = format!("{prefix}_PAGER"); + + let program = std::env::var(&binary_pager_var) + .ok() + .filter(|v| !v.is_empty()) + .or_else(|| { + std::env::var("PAGER") + .ok() + .filter(|v| !v.is_empty()) + }) + .unwrap_or_else(default_pager_program); + + Self { + program, + disabled: false, + } + } +} + +/// Platform default pager program. +fn default_pager_program() -> String { + if cfg!(windows) { + "more".to_string() + } else { + "less".to_string() + } +} + +/// A handle to a running pager child process. +/// +/// Implements `Write` — data written here is piped to the pager's stdin. +/// On drop, closes the pipe and waits for the pager to exit. +pub struct PagerHandle { + child: Child, + stdin: Option, +} + +impl PagerHandle { + /// Wait for the pager to exit. Called on drop, but can be called + /// explicitly to capture the exit status. + pub fn wait(mut self) -> io::Result<()> { + drop(self.stdin.take()); + let _ = self.child.wait(); + Ok(()) + } +} + +impl Write for PagerHandle { + fn write(&mut self, buf: &[u8]) -> io::Result { + match &mut self.stdin { + Some(stdin) => match stdin.write(buf) { + Err(e) if is_broken_pipe(&e) => Ok(buf.len()), + other => other, + }, + None => Ok(buf.len()), + } + } + + fn flush(&mut self) -> io::Result<()> { + match &mut self.stdin { + Some(stdin) => match stdin.flush() { + Err(e) if is_broken_pipe(&e) => Ok(()), + other => other, + }, + None => Ok(()), + } + } +} + +impl Drop for PagerHandle { + fn drop(&mut self) { + drop(self.stdin.take()); + let _ = self.child.wait(); + } +} + +/// Returns `true` if the error is a broken-pipe (`EPIPE`). +fn is_broken_pipe(e: &io::Error) -> bool { + e.kind() == io::ErrorKind::BrokenPipe +} + +/// Attempt to spawn a pager process. Returns `None` if the pager should +/// be skipped (non-TTY, disabled, or program not found). +/// +/// When `Some`, the caller writes output to the returned `PagerHandle` +/// instead of stdout. The pager is killed when the handle is dropped. +pub fn spawn_pager(config: &PagerConfig, label: &str) -> Option { + if config.disabled { + return None; + } + + if !std::io::stdout().is_terminal() { + return None; + } + + // Split $PAGER on whitespace so values like "less -R" work. + let parts: Vec<&str> = config.program.split_whitespace().collect(); + let (program, extra_args) = match parts.split_first() { + Some((prog, args)) => (*prog, args), + None => return None, + }; + + let mut cmd = Command::new(program); + cmd.args(extra_args) + .stdin(Stdio::piped()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // Set LESS defaults on the child process only (thread-safe). + // F = quit-if-one-screen, R = ANSI color only, X = no init/deinit. + if std::env::var("LESS").is_err() { + cmd.env("LESS", "FRX"); + } + + if is_less_compatible(program) && !extra_args.iter().any(|a| a.starts_with("-P")) { + cmd.arg(format!("-P{label}")); + } + + match cmd.spawn() { + Ok(mut child) => { + let stdin = child.stdin.take(); + Some(PagerHandle { child, stdin }) + } + Err(e) => { + tracing::debug!( + pager = %config.program, + error = %e, + "pager not available, falling back to stdout" + ); + None + } + } +} + +/// Check if the pager program is `less` or a less-compatible program. +/// Accepts a bare program name (already split from arguments). +fn is_less_compatible(program: &str) -> bool { + let basename = program.rsplit('/').next().unwrap_or(program); + let basename = basename.rsplit('\\').next().unwrap_or(basename); + basename == "less" || basename == "less.exe" +} + + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[test] + fn test_default_pager_program() { + let prog = default_pager_program(); + if cfg!(windows) { + assert_eq!(prog, "more"); + } else { + assert_eq!(prog, "less"); + } + } + + #[test] + fn test_is_less_compatible() { + assert!(is_less_compatible("less")); + assert!(is_less_compatible("/usr/bin/less")); + assert!(is_less_compatible("less.exe")); + assert!(is_less_compatible("C:\\Program Files\\Git\\usr\\bin\\less.exe")); + assert!(!is_less_compatible("more")); + assert!(!is_less_compatible("bat")); + assert!(!is_less_compatible("cat")); + } + + #[test] + #[serial] + fn test_pager_with_arguments_is_split() { + // Verify the config stores the full string including args + let saved = std::env::var("PAGER").ok(); + let saved_bin = std::env::var("SPLIT_TEST_PAGER").ok(); + std::env::set_var("PAGER", "less -R"); + std::env::remove_var("SPLIT_TEST_PAGER"); + + let config = PagerConfig::from_env("split-test"); + assert_eq!(config.program, "less -R"); + + // Verify splitting logic extracts program and args correctly + let parts: Vec<&str> = config.program.split_whitespace().collect(); + assert_eq!(parts[0], "less"); + assert_eq!(&parts[1..], &["-R"]); + + // Restore + match saved { + Some(p) => std::env::set_var("PAGER", p), + None => std::env::remove_var("PAGER"), + } + match saved_bin { + Some(p) => std::env::set_var("SPLIT_TEST_PAGER", p), + None => std::env::remove_var("SPLIT_TEST_PAGER"), + } + } + + #[test] + fn test_is_broken_pipe() { + let e = io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"); + assert!(is_broken_pipe(&e)); + + let e = io::Error::other("other"); + assert!(!is_broken_pipe(&e)); + } + + #[test] + fn test_pager_config_disabled_skips_spawn() { + let config = PagerConfig { + program: "less".to_string(), + disabled: true, + }; + assert!(spawn_pager(&config, "test").is_none()); + } + + #[test] + #[serial] + fn test_pager_config_from_env_defaults() { + // Clear env vars to test defaults + let key = "TEST_PAGER_CLI_PAGER"; + std::env::remove_var(key); + let saved_pager = std::env::var("PAGER").ok(); + std::env::remove_var("PAGER"); + + let config = PagerConfig::from_env("test-pager-cli"); + assert_eq!(config.program, default_pager_program()); + assert!(!config.disabled); + + // Restore + if let Some(p) = saved_pager { + std::env::set_var("PAGER", p); + } + } + + #[test] + #[serial] + fn test_pager_config_from_env_pager_var() { + let saved = std::env::var("PAGER").ok(); + std::env::set_var("PAGER", "bat"); + // Clear binary-specific var + std::env::remove_var("MY_CLI_PAGER"); + + let config = PagerConfig::from_env("my-cli"); + assert_eq!(config.program, "bat"); + + // Restore + match saved { + Some(p) => std::env::set_var("PAGER", p), + None => std::env::remove_var("PAGER"), + } + } + + #[test] + #[serial] + fn test_pager_config_from_env_binary_var_takes_precedence() { + let saved_pager = std::env::var("PAGER").ok(); + let saved_bin = std::env::var("MY_CLI_PAGER").ok(); + std::env::set_var("PAGER", "less"); + std::env::set_var("MY_CLI_PAGER", "bat"); + + let config = PagerConfig::from_env("my-cli"); + assert_eq!(config.program, "bat"); + + // Restore + match saved_pager { + Some(p) => std::env::set_var("PAGER", p), + None => std::env::remove_var("PAGER"), + } + match saved_bin { + Some(p) => std::env::set_var("MY_CLI_PAGER", p), + None => std::env::remove_var("MY_CLI_PAGER"), + } + } + + #[test] + #[serial] + fn test_pager_config_empty_env_falls_through() { + let saved_pager = std::env::var("PAGER").ok(); + let saved_bin = std::env::var("EMPTY_CLI_PAGER").ok(); + std::env::set_var("EMPTY_CLI_PAGER", ""); + std::env::set_var("PAGER", ""); + + let config = PagerConfig::from_env("empty-cli"); + assert_eq!(config.program, default_pager_program()); + + // Restore + match saved_pager { + Some(p) => std::env::set_var("PAGER", p), + None => std::env::remove_var("PAGER"), + } + match saved_bin { + Some(p) => std::env::set_var("EMPTY_CLI_PAGER", p), + None => std::env::remove_var("EMPTY_CLI_PAGER"), + } + } +} diff --git a/src/sdk_executor.rs b/src/sdk_executor.rs new file mode 100644 index 0000000..3284515 --- /dev/null +++ b/src/sdk_executor.rs @@ -0,0 +1,835 @@ +//! SDK execution bridge — implements the generated SDK's `RequestExecutor` +//! trait by routing through the CLI's existing HTTP/auth/retry stack. +//! +//! The [`CliExecutor`] struct holds references to the CLI's [`HttpConfig`], +//! [`DynAuthProvider`], global headers, and base-URL override. Its +//! [`execute`](CliExecutor::execute) method guarantees on-the-wire behavioral +//! parity with built-in commands: +//! +//! * Same TLS roots / proxy / timeouts (`HttpConfig::build_client`) +//! * Same auth application (`DynAuthProvider::apply`) +//! * Same retry logic ([`decide_retry`](crate::openapi::executor::decide_retry)) +//! * Same global-header injection +//! +//! **ADR-0001 compliant**: credentials stay inside `auth_provider.apply()` — +//! the executor never extracts or exposes resolved credentials. +//! +//! # Usage +//! +//! The generated CLI (FER-11028) will construct a `CliExecutor` from the +//! runtime `AppContext` and wrap it in `Arc` for the +//! co-vendored SDK crate's `HttpClient::with_executor()`. + +use std::future::Future; +use std::pin::Pin; + +use reqwest::{Client, Request, Response}; + +use crate::auth::{DynAuthProvider, EndpointAuthMetadata}; +use crate::error::CliError; +use crate::http::HttpConfig; +use crate::openapi::discovery::RetriesConfig; +use crate::openapi::executor::{decide_retry, RetryOutcome}; + +// --------------------------------------------------------------------------- +// Trait mirror — matches the SDK's `RequestExecutor` signature exactly. +// --------------------------------------------------------------------------- + +/// Mirror of the generated SDK's `RequestExecutor` trait. +/// +/// Defined here so the cli-sdk can implement and test the executor without +/// depending on a generated crate. The CLI generator (FER-11028) emits a +/// thin adapter that bridges this implementation to the SDK's concrete trait. +/// +/// The error type is [`SdkError`] rather than `reqwest::Error` so that +/// pre-send failures (auth, validation) can be surfaced without sending +/// an unauthenticated request. +pub trait SdkRequestExecutor: Send + Sync { + /// Execute a fully-built HTTP request through the CLI's transport stack. + fn execute( + &self, + request: Request, + ) -> Pin> + Send + '_>>; +} + +// --------------------------------------------------------------------------- +// CliExecutor — the concrete implementation +// --------------------------------------------------------------------------- + +/// Executes SDK-originated HTTP requests through the CLI's transport stack. +/// +/// Constructed once per CLI invocation and shared (via `Arc`) across all SDK +/// client instances within that process. The `reqwest::Client` is built once +/// at construction time and reused across all requests for connection pooling. +pub struct CliExecutor { + client: Client, + auth_provider: DynAuthProvider, + global_headers: Vec<(String, String)>, + base_url_override: Option, + retries: RetriesConfig, + /// `--debug`: dump request (and response status/headers) to stderr. + debug: bool, + /// Spec-declared credential header names, so `--debug` redacts an + /// `apiKey`-in-header scheme's value here exactly as the OpenAPI path does. + sensitive_headers: Vec, +} + +impl CliExecutor { + /// Create a new executor wired to the CLI's runtime context. + /// + /// # Panics + /// + /// Panics if `HttpConfig::build_client()` fails (invalid TLS config, etc.). + /// This surfaces errors at construction time rather than per-request. + pub fn new( + http_config: HttpConfig, + auth_provider: DynAuthProvider, + global_headers: Vec<(String, String)>, + base_url_override: Option, + ) -> Self { + let client = http_config + .build_client() + .expect("HttpConfig::build_client failed"); + Self { + client, + auth_provider, + global_headers, + base_url_override, + retries: RetriesConfig::default(), + debug: false, + sensitive_headers: Vec::new(), + } + } + + /// Enable `--debug` HTTP dumping, redacting `sensitive_headers` on top of + /// the well-known credential header names. + /// + /// Separate from [`Self::new`] so existing callers are unaffected. Without + /// this, `--debug` was silent for custom commands — the dump lived only in + /// the OpenAPI executor, so the flag printed nothing on the one path a + /// handler uses, which is exactly when it is wanted. + pub fn with_debug(mut self, debug: bool, sensitive_headers: Vec) -> Self { + self.debug = debug; + self.sensitive_headers = sensitive_headers; + self + } + + /// Override the default retry configuration. + pub fn with_retries(mut self, retries: RetriesConfig) -> Self { + self.retries = retries; + self + } + + /// Execute a single request with auth, global headers, and retries. + /// + /// The incoming `Request` from the SDK contains the endpoint URL, HTTP + /// method, body, and any user-set headers. This method: + /// 1. Decomposes the request into a `RequestBuilder` + /// 2. Applies auth via `auth_provider.apply()` + /// 3. Applies global headers + /// 4. Optionally overrides the base URL + /// 5. Sends with retry logic (reusing the pooled `Client`) + async fn execute_inner(&self, request: Request) -> Result { + let client = &self.client; + + let method = request.method().clone(); + let url = self.resolve_url(request.url().clone()); + let headers = request.headers().clone(); + // Capture body bytes for retry support. SDK requests are typically + // small JSON payloads so buffering is acceptable. + let body_bytes: Option = request.body().map(|b| { + b.as_bytes() + .map(bytes::Bytes::copy_from_slice) + .expect( + "CliExecutor does not support streaming request bodies; \ + SDK requests must be fully buffered", + ) + }); + + let http_method_str = method.as_str().to_uppercase(); + + // Borrowed views for the debug dump; `Vec` -> `&[&str]`. + let sensitive: Vec<&str> = self.sensitive_headers.iter().map(String::as_str).collect(); + + let mut retry_attempt: u32 = 0; + loop { + let builder = + self.build_request(client, &method, &url, &headers, body_bytes.as_ref())?; + if self.debug { + // Dump the fully-built request — after auth and global headers + // are applied, so what is printed is what goes on the wire. + // Cloning is only done under `--debug`. + if let Some(built) = builder.try_clone().and_then(|b| b.build().ok()) { + let body_str = built + .body() + .and_then(|b| b.as_bytes()) + .map(|b| String::from_utf8_lossy(b).to_string()); + crate::debug::dump_request( + built.method().as_str(), + built.url().as_str(), + built.headers(), + body_str.as_deref(), + &sensitive, + &[], + ); + } + } + let started = std::time::Instant::now(); + + let resp = match builder.send().await { + Ok(resp) => { + let status = resp.status().as_u16(); + if self.debug { + crate::debug::dump_response_headers_only( + status, + started.elapsed().as_millis() as u64, + resp.headers(), + &sensitive, + ); + } + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let outcome = RetryOutcome { + status: Some(status), + retry_after: retry_after.as_deref(), + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + &self.retries, + &http_method_str, + true, // SDK requests are treated as idempotent + false, + ) { + retry_attempt += 1; + tokio::time::sleep(delay).await; + continue; + } + resp + } + Err(e) => { + let outcome = RetryOutcome { + status: None, + retry_after: None, + }; + if let Some(delay) = decide_retry( + retry_attempt, + &outcome, + &self.retries, + &http_method_str, + true, + false, + ) { + retry_attempt += 1; + tokio::time::sleep(delay).await; + continue; + } + return Err(SdkError::from(e)); + } + }; + + return Ok(resp); + } + } + + /// Decompose parts back into a `RequestBuilder`, apply auth and headers. + /// + /// Returns `Err` if the auth provider fails — the caller must NOT + /// fall back to sending without credentials (fail-closed, consistent + /// with `build_http_request` in `openapi/executor.rs` and + /// `graphql/executor.rs`). + fn build_request( + &self, + client: &Client, + method: &reqwest::Method, + url: &reqwest::Url, + headers: &reqwest::header::HeaderMap, + body_bytes: Option<&bytes::Bytes>, + ) -> Result { + let mut builder = client.request(method.clone(), url.clone()); + for (name, value) in headers.iter() { + builder = builder.header(name, value); + } + if let Some(body) = body_bytes { + builder = builder.body(body.clone()); + } + + // Apply auth — ADR-0001: credentials stay inside apply(). + // Fail closed: if the provider returns an error, we surface it + // rather than silently sending without credentials. + let endpoint = EndpointAuthMetadata::unspecified(); + builder = match self.auth_provider.apply(builder, &endpoint) { + Ok(b) => b, + Err(e) => { + tracing::warn!( + "CLI auth provider failed during SDK request execution; \ + request will NOT be sent: {e}" + ); + return Err(SdkError::Auth(format!( + "CLI auth failed: {e}" + ))); + } + }; + + // Apply global headers (lower precedence than per-request headers + // already set by the SDK, but reqwest appends rather than replaces + // for duplicate names — auth headers win because they're set first). + for (name, value) in &self.global_headers { + builder = builder.header(name.as_str(), value.as_str()); + } + + Ok(builder) + } + + /// Resolve the final URL, applying base-URL override if configured. + /// + /// Replaces scheme + host + port from the override. If the override has a + /// non-root path (e.g. `http://localhost:8080/api/v2`), that path is + /// prepended to the original request path so that a request to + /// `https://api.example.com/users` becomes `http://localhost:8080/api/v2/users`. + /// + /// Note: The generated glue (FER-11028) typically sets the SDK's own + /// `base_url` to the override, so this method acts as a safety net for + /// cases where the SDK was constructed without the override. + fn resolve_url(&self, mut url: reqwest::Url) -> reqwest::Url { + if let Some(ref override_base) = self.base_url_override { + if let Ok(base) = reqwest::Url::parse(override_base) { + url.set_scheme(base.scheme()).ok(); + if let Some(host) = base.host_str() { + url.set_host(Some(host)).ok(); + } + url.set_port(base.port()).ok(); + let base_path = base.path().trim_end_matches('/'); + if !base_path.is_empty() && base_path != "/" { + let original_path = url.path().to_string(); + url.set_path(&format!("{}{}", base_path, original_path)); + } + } + } + url + } +} + +impl SdkRequestExecutor for CliExecutor { + fn execute( + &self, + request: Request, + ) -> Pin> + Send + '_>> { + Box::pin(self.execute_inner(request)) + } +} + +// --------------------------------------------------------------------------- +// block_on helper +// --------------------------------------------------------------------------- + +/// Execute an async SDK operation from synchronous custom-command context. +/// +/// Uses the existing pattern: `block_in_place` parks the current tokio +/// worker thread so a nested `block_on` is legal. Converts the SDK's +/// error type into [`CliError`] via the error bridge. +/// +/// # Panics +/// +/// Panics if called outside a tokio runtime (should never happen — CLI +/// binaries always run inside `#[tokio::main]`). +pub fn block_on(future: F) -> Result +where + F: Future>, + E: Into, +{ + tokio::task::block_in_place(|| { + let handle = tokio::runtime::Handle::current(); + handle.block_on(future).map_err(|e| e.into().into_cli_error()) + }) +} + +// --------------------------------------------------------------------------- +// Error bridge: SdkError → CliError +// --------------------------------------------------------------------------- + +/// Wrapper around errors originating from the generated SDK. +/// +/// The generated SDK uses `ApiError` with variants for HTTP status, network, +/// and timeout errors. This struct provides a uniform bridge to [`CliError`]. +#[derive(Debug)] +pub enum SdkError { + /// HTTP response with a non-success status code. + Http { + status: u16, + body: String, + }, + /// Network-level failure (DNS, connection refused, TLS handshake, etc.). + Network(String), + /// Request timed out. + Timeout(String), + /// Authentication failure (credential resolution, token refresh, etc.). + Auth(String), + /// Any other SDK error. + Other(String), +} + +impl SdkError { + /// Convert into the CLI's native error type. + pub fn into_cli_error(self) -> CliError { + match self { + Self::Http { status, body } => CliError::Api { + code: status, + message: body, + reason: http_status_reason(status).to_string(), + }, + Self::Network(msg) => { + CliError::Other(anyhow::anyhow!("SDK network error: {msg}")) + } + Self::Timeout(msg) => { + CliError::Other(anyhow::anyhow!("SDK request timeout: {msg}")) + } + Self::Auth(msg) => CliError::Auth(msg), + Self::Other(msg) => { + CliError::Other(anyhow::anyhow!("SDK error: {msg}")) + } + } + } +} + +impl std::fmt::Display for SdkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Http { status, body } => write!(f, "HTTP error {status}: {body}"), + Self::Network(msg) => write!(f, "network error: {msg}"), + Self::Timeout(msg) => write!(f, "request timeout: {msg}"), + Self::Auth(msg) => write!(f, "authentication error: {msg}"), + Self::Other(msg) => write!(f, "SDK error: {msg}"), + } + } +} + +impl std::error::Error for SdkError {} + +impl From for SdkError { + fn from(e: reqwest::Error) -> Self { + if e.is_timeout() { + Self::Timeout(e.to_string()) + } else if e.is_connect() || e.is_redirect() { + Self::Network(e.to_string()) + } else if let Some(status) = e.status() { + Self::Http { + status: status.as_u16(), + body: e.to_string(), + } + } else { + Self::Network(e.to_string()) + } + } +} + +/// Map an HTTP status code to a short reason string for [`CliError::Api`]. +fn http_status_reason(status: u16) -> &'static str { + match status { + 400 => "badRequest", + 401 => "unauthorized", + 403 => "forbidden", + 404 => "notFound", + 408 => "requestTimeout", + 409 => "conflict", + 422 => "unprocessableEntity", + 429 => "rateLimited", + 500 => "internalServerError", + 502 => "badGateway", + 503 => "serviceUnavailable", + 504 => "gatewayTimeout", + _ => "httpError", + } +} + + + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use crate::auth::{no_auth_provider, AuthProvider, EndpointAuthMetadata}; + + /// Auth provider that always returns a hard error. + #[derive(Debug)] + struct FailingAuthProvider; + + impl AuthProvider for FailingAuthProvider { + fn name(&self) -> &str { + "failing-test" + } + + fn has_credentials(&self) -> bool { + true + } + + fn apply( + &self, + _request: reqwest::RequestBuilder, + _endpoint: &EndpointAuthMetadata, + ) -> Result { + Err(CliError::Auth("token refresh failed".into())) + } + } + + fn failing_auth_provider() -> DynAuthProvider { + Arc::new(FailingAuthProvider) + } + + #[test] + fn sdk_error_auth_maps_to_cli_auth() { + let err = SdkError::Auth("token refresh failed".into()); + let cli_err = err.into_cli_error(); + assert_eq!(cli_err.exit_code(), CliError::EXIT_CODE_AUTH); + match cli_err { + CliError::Auth(msg) => { + assert!(msg.contains("token refresh failed")); + } + _ => panic!("expected CliError::Auth, got: {cli_err:?}"), + } + } + + #[test] + fn sdk_error_http_maps_to_cli_api() { + let err = SdkError::Http { + status: 404, + body: "not found".into(), + }; + let cli_err = err.into_cli_error(); + match cli_err { + CliError::Api { code, message, reason } => { + assert_eq!(code, 404); + assert_eq!(message, "not found"); + assert_eq!(reason, "notFound"); + } + _ => panic!("expected CliError::Api"), + } + } + + #[test] + fn sdk_error_network_maps_to_cli_other() { + let err = SdkError::Network("connection refused".into()); + let cli_err = err.into_cli_error(); + assert!(matches!(cli_err, CliError::Other(_))); + assert!(cli_err.to_string().contains("network error")); + } + + #[test] + fn sdk_error_timeout_maps_to_cli_other() { + let err = SdkError::Timeout("timed out after 30s".into()); + let cli_err = err.into_cli_error(); + assert!(matches!(cli_err, CliError::Other(_))); + assert!(cli_err.to_string().contains("timeout")); + } + + #[test] + fn sdk_error_display_formats() { + assert_eq!( + SdkError::Http { status: 404, body: "not found".into() }.to_string(), + "HTTP error 404: not found" + ); + assert_eq!( + SdkError::Network("connection refused".into()).to_string(), + "network error: connection refused" + ); + assert_eq!( + SdkError::Timeout("after 30s".into()).to_string(), + "request timeout: after 30s" + ); + assert_eq!( + SdkError::Auth("bad token".into()).to_string(), + "authentication error: bad token" + ); + assert_eq!( + SdkError::Other("unknown".into()).to_string(), + "SDK error: unknown" + ); + } + + #[test] + fn sdk_error_implements_std_error() { + let err: Box = + Box::new(SdkError::Network("test".into())); + let downcast = err.downcast::(); + assert!(downcast.is_ok()); + assert!(matches!(*downcast.unwrap(), SdkError::Network(_))); + } + + #[test] + fn http_status_reason_known_codes() { + assert_eq!(http_status_reason(401), "unauthorized"); + assert_eq!(http_status_reason(429), "rateLimited"); + assert_eq!(http_status_reason(503), "serviceUnavailable"); + assert_eq!(http_status_reason(999), "httpError"); + } + + #[test] + fn cli_executor_new_default_retries() { + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![], + None, + ); + assert!(executor.retries.enabled); + assert!(executor.retries.max_attempts > 0); + } + + #[test] + fn cli_executor_with_retries_override() { + let http = HttpConfig::new("test-cli").unwrap(); + let custom = RetriesConfig { + enabled: false, + ..Default::default() + }; + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![], + None, + ) + .with_retries(custom); + assert!(!executor.retries.enabled); + } + + #[test] + fn resolve_url_no_override() { + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new(http, no_auth_provider(), vec![], None); + let url = reqwest::Url::parse("https://api.example.com/v1/users").unwrap(); + let resolved = executor.resolve_url(url.clone()); + assert_eq!(resolved, url); + } + + #[test] + fn resolve_url_with_override() { + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![], + Some("http://localhost:8080".into()), + ); + let url = reqwest::Url::parse("https://api.example.com/v1/users?page=1").unwrap(); + let resolved = executor.resolve_url(url); + assert_eq!(resolved.scheme(), "http"); + assert_eq!(resolved.host_str(), Some("localhost")); + assert_eq!(resolved.port(), Some(8080)); + assert_eq!(resolved.path(), "/v1/users"); + assert_eq!(resolved.query(), Some("page=1")); + } + + #[test] + fn resolve_url_with_path_bearing_override() { + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![], + Some("http://localhost:8080/api/v2".into()), + ); + let url = reqwest::Url::parse("https://api.example.com/users?page=1").unwrap(); + let resolved = executor.resolve_url(url); + assert_eq!(resolved.scheme(), "http"); + assert_eq!(resolved.host_str(), Some("localhost")); + assert_eq!(resolved.port(), Some(8080)); + assert_eq!(resolved.path(), "/api/v2/users"); + assert_eq!(resolved.query(), Some("page=1")); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn block_on_converts_sdk_error() { + let result: Result<(), CliError> = block_on(async { + Err::<(), SdkError>(SdkError::Http { + status: 500, + body: "internal error".into(), + }) + }); + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + CliError::Api { code, .. } => assert_eq!(code, 500), + _ => panic!("expected CliError::Api"), + } + } + + #[tokio::test] + async fn debug_dumping_does_not_alter_the_request_or_response() { + // `--debug` was inert on this path: every dump lived in the OpenAPI + // executor, so a custom command printed nothing. Now it dumps here — + // and dumping must stay observational, so the request still carries its + // auth/global headers and the response is still fully readable by the + // caller (the dump clones rather than consuming). + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::header("X-Custom", "value")) + .and(wiremock::matchers::body_string_contains("payload")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("response-body")) + .expect(1) + .mount(&mock_server) + .await; + + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![("X-Custom".into(), "value".into())], + None, + ) + .with_debug(true, vec!["xi-api-key".to_string()]); + + let client = reqwest::Client::new(); + let request = client + .post(format!("{}/test", mock_server.uri())) + .body("payload") + .build() + .unwrap(); + + let resp = executor.execute_inner(request).await.unwrap(); + assert_eq!(resp.status().as_u16(), 200); + // The body must survive the dump — it is handed to the SDK client next. + assert_eq!(resp.text().await.unwrap(), "response-body"); + } + + #[tokio::test] + async fn execute_applies_global_headers() { + // Use wiremock to verify headers are applied + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::header("X-Custom", "value")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("ok")) + .mount(&mock_server) + .await; + + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![("X-Custom".into(), "value".into())], + None, + ); + + let client = reqwest::Client::new(); + let request = client + .get(format!("{}/test", mock_server.uri())) + .build() + .unwrap(); + + let resp = executor.execute_inner(request).await.unwrap(); + assert_eq!(resp.status().as_u16(), 200); + } + + #[tokio::test] + async fn execute_applies_base_url_override() { + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/v1/data")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("overridden")) + .mount(&mock_server) + .await; + + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + no_auth_provider(), + vec![], + Some(mock_server.uri()), + ); + + let client = reqwest::Client::new(); + // Build request against original host — override should redirect + let request = client + .get("https://api.example.com/v1/data") + .build() + .unwrap(); + + let resp = executor.execute_inner(request).await.unwrap(); + assert_eq!(resp.status().as_u16(), 200); + let text = resp.text().await.unwrap(); + assert_eq!(text, "overridden"); + } + + #[tokio::test] + async fn execute_retries_on_500() { + use std::sync::atomic::{AtomicU32, Ordering}; + + let mock_server = wiremock::MockServer::start().await; + let call_count = Arc::new(AtomicU32::new(0)); + let cc = call_count.clone(); + + wiremock::Mock::given(wiremock::matchers::method("GET")) + .respond_with(move |_req: &wiremock::Request| { + let n = cc.fetch_add(1, Ordering::SeqCst); + if n == 0 { + wiremock::ResponseTemplate::new(500) + } else { + wiremock::ResponseTemplate::new(200) + .set_body_string("success") + } + }) + .mount(&mock_server) + .await; + + let http = HttpConfig::new("test-cli").unwrap(); + let retries = RetriesConfig { + enabled: true, + max_attempts: 3, + base_delay_ms: 10, // short for tests + factor: 1.0, + jitter: 0.0, + }; + let executor = CliExecutor::new(http, no_auth_provider(), vec![], None) + .with_retries(retries); + + let client = reqwest::Client::new(); + let request = client + .get(format!("{}/retry-test", mock_server.uri())) + .build() + .unwrap(); + + let resp = executor.execute_inner(request).await.unwrap(); + assert_eq!(resp.status().as_u16(), 200); + assert!(call_count.load(Ordering::SeqCst) >= 2); + } + + #[tokio::test] + async fn execute_fails_closed_on_auth_error() { + let mock_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .respond_with(wiremock::ResponseTemplate::new(200)) + .expect(0) // must NOT receive any request + .mount(&mock_server) + .await; + + let http = HttpConfig::new("test-cli").unwrap(); + let executor = CliExecutor::new( + http, + failing_auth_provider(), + vec![], + None, + ); + + let client = reqwest::Client::new(); + let request = client + .get(format!("{}/should-not-be-sent", mock_server.uri())) + .build() + .unwrap(); + + let result = executor.execute_inner(request).await; + assert!(result.is_err(), "expected auth failure to propagate as error"); + let err_msg = match &result.unwrap_err() { + SdkError::Auth(msg) => msg.clone(), + other => panic!("expected SdkError::Auth, got: {other:?}"), + }; + assert!( + err_msg.contains("auth"), + "error should mention auth: {err_msg}" + ); + // wiremock's expect(0) will panic on drop if any request was received, + // verifying the request was never sent (fail-closed). + } +} diff --git a/src/stability.rs b/src/stability.rs new file mode 100644 index 0000000..82a0536 --- /dev/null +++ b/src/stability.rs @@ -0,0 +1,127 @@ +//! Stability levels for commands in the CLI tree. +//! +//! Commands can be annotated with a [`Stability`] level. Pre-GA commands +//! are hidden from `--help` and gated behind `--maturity `. + +/// Stability level for a command or command group. +/// +/// Ordered most-mature → least: `Stable > Rc > Beta > Alpha > EarlyAccess`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Stability { + Stable, + Rc, + Beta, + Alpha, + EarlyAccess, + Deprecated { + message: String, + replacement: Option, + removed_in: Option, + }, + Removed { + message: String, + }, +} + +impl Stability { + /// Numeric rank for maturity comparison. Lower = more mature. + /// `Deprecated` and `Removed` are special — they are always visible + /// (with a badge) and don't participate in maturity gating. + pub fn rank(&self) -> u8 { + match self { + Self::Stable => 0, + Self::Rc => 1, + Self::Beta => 2, + Self::Alpha => 3, + Self::EarlyAccess => 4, + Self::Deprecated { .. } => 0, // always visible + Self::Removed { .. } => 255, + } + } + + /// Badge text shown in `--help` output (e.g. `[beta]`, `[deprecated]`). + pub fn badge(&self) -> Option<&'static str> { + match self { + Self::Stable => None, + Self::Rc => Some("[rc]"), + Self::Beta => Some("[beta]"), + Self::Alpha => Some("[alpha]"), + Self::EarlyAccess => Some("[early-access]"), + Self::Deprecated { .. } => Some("[deprecated]"), + Self::Removed { .. } => Some("[removed]"), + } + } + + /// Returns `true` if this command should be visible at the given + /// maturity level (lower rank = more mature). + pub fn visible_at(&self, maturity_rank: u8) -> bool { + match self { + // Deprecated commands are always visible (with badge). + Self::Deprecated { .. } => true, + // Removed commands are never visible. + Self::Removed { .. } => false, + // GA and pre-GA: visible if the user's threshold allows it. + _ => self.rank() <= maturity_rank, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_ordering() { + assert!(Stability::Stable.rank() < Stability::Rc.rank()); + assert!(Stability::Rc.rank() < Stability::Beta.rank()); + assert!(Stability::Beta.rank() < Stability::Alpha.rank()); + assert!(Stability::Alpha.rank() < Stability::EarlyAccess.rank()); + } + + #[test] + fn visible_at_threshold() { + // Stable is always visible at default (0) + assert!(Stability::Stable.visible_at(0)); + // Beta is NOT visible at default (0) + assert!(!Stability::Beta.visible_at(0)); + // Beta IS visible at rank 2+ + assert!(Stability::Beta.visible_at(2)); + assert!(Stability::Beta.visible_at(4)); + } + + #[test] + fn deprecated_always_visible() { + let dep = Stability::Deprecated { + message: "use v2".into(), + replacement: None, + removed_in: None, + }; + assert!(dep.visible_at(0)); + assert!(dep.visible_at(4)); + } + + #[test] + fn removed_never_visible() { + let rem = Stability::Removed { + message: "gone".into(), + }; + assert!(!rem.visible_at(0)); + assert!(!rem.visible_at(255)); + } + + #[test] + fn badge_text() { + assert_eq!(Stability::Stable.badge(), None); + assert_eq!(Stability::Beta.badge(), Some("[beta]")); + assert_eq!( + Stability::Deprecated { + message: String::new(), + replacement: None, + removed_in: None, + } + .badge(), + Some("[deprecated]") + ); + } +} diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..9b4df3b --- /dev/null +++ b/src/text.rs @@ -0,0 +1,548 @@ +// SPDX-License-Identifier: Apache-2.0 + +use unicode_normalization::UnicodeNormalization; + +/// Max chars for CLI `--help` method descriptions (terminal-width friendly). +pub const CLI_DESCRIPTION_LIMIT: usize = 200; + +/// Convert a parameter name to an idiomatic kebab-case CLI flag. +/// +/// Handles snake_case (`min_start_time` → `min-start-time`), camelCase +/// (`pageToken` → `page-token`), and Header-Case names that already +/// contain dashes (`Idempotency-Key` → `idempotency-key`). Adjacent +/// separator characters never produce double dashes — both `_` and `-` +/// collapse to a single `-`, and an uppercase letter that immediately +/// follows a separator is *not* preceded by an additional dash. +pub fn to_kebab_flag(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch == '_' || ch == '-' { + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } else if ch.is_uppercase() { + if i > 0 && !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_lowercase().next().unwrap()); + } else { + result.push(ch); + } + } + result +} + +/// Convert an identifier to SCREAMING_SNAKE_CASE, the canonical env-var +/// spelling for `--` flags. +/// +/// Mirrors [`to_kebab_flag`] then uppercases and swaps hyphens for +/// underscores: `pageToken` → `PAGE_TOKEN`, `min_start_time` → +/// `MIN_START_TIME`, `garden-id` → `GARDEN_ID`. Used by +/// `x-fern-sdk-variables` to derive the env-var fallback for each global. +pub fn to_screaming_snake(s: &str) -> String { + to_kebab_flag(s).to_ascii_uppercase().replace('-', "_") +} + +/// Sanitize an OpenAPI parameter wire name into a valid CLI flag name. +/// +/// Pipeline (applied in order): +/// +/// 1. **Reject** names containing ASCII control characters (`\x00`–`\x1f`, +/// `\x7f`) or whitespace (space, tab, newline, carriage return). +/// 2. **NFKD transliterate**: decompose Unicode, strip combining marks, and +/// drop zero-width / bidi-control codepoints. Reject names that still +/// contain non-ASCII characters after decomposition (CJK, RTL, etc.). +/// 3. **Kebab-case normalize**: apply camelCase / snake_case / PascalCase → +/// kebab-case conversion, and replace any remaining character outside +/// `[A-Za-z0-9]` with `-`. +/// 4. **Tidy**: collapse repeated `-` to one, trim leading/trailing `-`. +/// +/// Returns `Ok(flag_name)` or `Err(message)` describing why the name is +/// invalid. The caller is responsible for reserved-name and collision +/// checks (those require cross-parameter context). +pub fn sanitize_flag_name(wire_name: &str) -> Result { + if wire_name.is_empty() { + return Err("Parameter name is empty".to_string()); + } + + // Step 1: reject control characters and whitespace. + for ch in wire_name.chars() { + if ch.is_ascii_control() { + return Err(format!( + "Parameter '{wire_name}' contains control character U+{:04X}", + ch as u32, + )); + } + if ch.is_whitespace() { + return Err(format!( + "Parameter '{wire_name}' contains whitespace character U+{:04X}", + ch as u32, + )); + } + } + + // Step 2: NFKD decompose → strip combining marks and zero-width/bidi + // codepoints → reject remaining non-ASCII. + let decomposed: String = wire_name + .nfkd() + .filter(|ch| { + // Drop combining marks (Unicode category M). + if is_combining_mark(*ch) { + return false; + } + // Drop zero-width and bidi-control codepoints. + if is_zero_width_or_bidi(*ch) { + return false; + } + true + }) + .collect(); + + for ch in decomposed.chars() { + if !ch.is_ascii() { + return Err(format!( + "Parameter '{wire_name}' contains non-transliterable character '{ch}' (U+{:04X})", + ch as u32, + )); + } + } + + // Steps 3 + 4: kebab-case normalize with extended sanitization, then tidy. + let sanitized = to_kebab_flag_sanitized(&decomposed); + + if sanitized.is_empty() { + return Err(format!( + "Parameter '{wire_name}' sanitizes to an empty string", + )); + } + + Ok(sanitized) +} + +/// Like [`to_kebab_flag`] but treats *any* non-alphanumeric character as a +/// word separator (not just `_` and `-`). This catches `:`, `.`, `[`, `]`, +/// `{`, `}`, `+`, `,`, `=`, etc. — all the shell-special and +/// POSIX-flag-name-violating characters listed in the sanitization spec. +fn to_kebab_flag_sanitized(s: &str) -> String { + let mut result = String::with_capacity(s.len() + 4); + for (i, ch) in s.chars().enumerate() { + if ch.is_ascii_alphanumeric() { + if ch.is_ascii_uppercase() { + // camelCase word boundary: insert dash before uppercase + // unless we're at the start or already have a dash. + if i > 0 && !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } else { + // Any non-alphanumeric → separator dash (collapsed later). + if !result.is_empty() && !result.ends_with('-') { + result.push('-'); + } + } + } + // Trim trailing dashes. + while result.ends_with('-') { + result.pop(); + } + result +} + +/// Returns `true` for Unicode combining marks (category M: Mn, Mc, Me). +fn is_combining_mark(ch: char) -> bool { + // Combining Diacritical Marks: U+0300–U+036F + // Combining Diacritical Marks Extended: U+1AB0–U+1AFF + // Combining Diacritical Marks Supplement: U+1DC0–U+1DFF + // Combining Diacritical Marks for Symbols: U+20D0–U+20FF + // Combining Half Marks: U+FE20–U+FE2F + matches!(ch, + '\u{0300}'..='\u{036F}' + | '\u{1AB0}'..='\u{1AFF}' + | '\u{1DC0}'..='\u{1DFF}' + | '\u{20D0}'..='\u{20FF}' + | '\u{FE20}'..='\u{FE2F}' + ) +} + +/// Returns `true` for zero-width and bidi-control codepoints that should +/// be silently stripped from parameter names. +fn is_zero_width_or_bidi(ch: char) -> bool { + matches!(ch, + '\u{200B}' // ZERO WIDTH SPACE + | '\u{200C}' // ZERO WIDTH NON-JOINER + | '\u{200D}' // ZERO WIDTH JOINER + | '\u{FEFF}' // ZERO WIDTH NO-BREAK SPACE (BOM) + | '\u{200E}' // LEFT-TO-RIGHT MARK + | '\u{200F}' // RIGHT-TO-LEFT MARK + | '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO + | '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI + ) +} + +/// Truncates a description string to `max_chars` using smart boundaries. +/// +/// When `strip_links` is true, markdown links `[text](url)` are replaced with +/// just `text` to reclaim character budget (useful for CLI help / frontmatter). +/// When false, links are preserved (useful for skill body text where agents can +/// follow URLs). +/// +/// Truncation strategy: +/// 1. If a complete sentence (ending in `. `) fits within the limit, truncate there. +/// 2. Otherwise, break at the last word boundary (space) and append `…`. +/// 3. If no space exists, hard-cut at `max_chars - 1` and append `…`. +pub fn truncate_description(desc: &str, max_chars: usize, strip_links: bool) -> String { + if max_chars == 0 { + return String::new(); + } + + let cleaned = if strip_links { + strip_markdown_links(desc) + } else { + desc.to_string() + }; + let trimmed = cleaned.trim(); + + // Count chars (UTF-8 safe) + let char_count = trimmed.chars().count(); + if char_count <= max_chars { + return trimmed.to_string(); + } + + // Collect the first `max_chars` characters as a string to search within. + let prefix: String = trimmed.chars().take(max_chars).collect(); + + // Try to find the last complete sentence within the limit. + // A sentence ends with ". " followed by more text, or "." at the end of + // the prefix. We look for the last ". " to find a sentence boundary. + if let Some(sentence_end) = find_last_sentence_boundary(&prefix) { + let truncated: String = trimmed.chars().take(sentence_end).collect(); + return truncated; + } + + // Fall back to last word boundary (space) within the limit. + if let Some(last_space) = rfind_char_boundary(&prefix, ' ') { + let truncated: String = trimmed.chars().take(last_space).collect(); + return format!("{truncated}…"); + } + + // Hard cut — no spaces at all + let truncated: String = trimmed.chars().take(max_chars - 1).collect(); + format!("{truncated}…") +} + +/// Strips markdown-style links `[text](url)` and replaces them with just `text`. +fn strip_markdown_links(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let chars: Vec = s.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + if chars[i] == '[' { + // Look for the closing ] followed by ( + if let Some(close_bracket) = find_char_from(&chars, ']', i + 1) { + if close_bracket + 1 < len && chars[close_bracket + 1] == '(' { + if let Some(close_paren) = find_char_from(&chars, ')', close_bracket + 2) { + // Found a complete [text](url) — emit just the text + result.extend(&chars[i + 1..close_bracket]); + i = close_paren + 1; + continue; + } + } + } + } + result.push(chars[i]); + i += 1; + } + + result +} + +/// Finds the character-index of `target` starting from position `from`. +fn find_char_from(chars: &[char], target: char, from: usize) -> Option { + chars[from..] + .iter() + .position(|&c| c == target) + .map(|p| from + p) +} + +/// Finds the last sentence boundary within a char-indexed string. +/// A sentence boundary is a position right after ". " where we can cleanly cut. +/// Returns the char-count to include (up to and including the period). +fn find_last_sentence_boundary(prefix: &str) -> Option { + let chars: Vec = prefix.chars().collect(); + let mut last_boundary = None; + + for (i, _) in chars.iter().enumerate() { + if chars[i] == '.' { + let after_period = i + 1; + // Sentence boundary: period followed by a space, or period at end of prefix + if after_period == chars.len() + || (after_period < chars.len() && chars[after_period] == ' ') + { + last_boundary = Some(after_period); + } + } + } + + last_boundary +} + +/// Finds the last occurrence of `target` in a string, returning its char-index. +fn rfind_char_boundary(s: &str, target: char) -> Option { + let chars: Vec = s.chars().collect(); + chars.iter().rposition(|&c| c == target) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_desc_unchanged() { + let desc = "Lists all files."; + assert_eq!(truncate_description(desc, 200, true), "Lists all files."); + } + + #[test] + fn truncate_at_sentence_boundary() { + let desc = "Creates a file in Drive. This method supports multipart upload. See the guide for details on how to use it."; + // At limit 30, only the first sentence fits before the sentence boundary. + let result = truncate_description(desc, 30, true); + assert_eq!(result, "Creates a file in Drive."); + + // At limit 70, both first and second sentences fit. + let result = truncate_description(desc, 70, true); + assert_eq!( + result, + "Creates a file in Drive. This method supports multipart upload." + ); + } + + #[test] + fn truncate_at_word_boundary() { + let desc = "Create a guest user with access to a subset of Workspace capabilities"; + let result = truncate_description(desc, 50, true); + // Should cut at the last space before char 50 + assert!(result.ends_with('…')); + assert!(result.len() <= 55); // 50 chars + ellipsis + assert!(!result.contains("capabil")); // Should not cut mid-word + } + + #[test] + fn hard_cut_no_spaces() { + let desc = "abcdefghijklmnopqrstuvwxyz"; + let result = truncate_description(desc, 10, true); + assert_eq!(result, "abcdefghi…"); + } + + #[test] + fn strips_markdown_links() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha."; + let result = truncate_description(desc, 200, true); + assert_eq!( + result, + "Create a guest user with access to a subset of Workspace capabilities. This feature is in Alpha." + ); + assert!(!result.contains("https://")); + assert!(!result.contains('[')); + } + + #[test] + fn preserves_links_when_strip_links_false() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is in Alpha."; + let result = truncate_description(desc, 500, false); + assert!(result.contains("https://support.google.com")); + assert!(result.contains("[subset of Workspace capabilities]")); + } + + #[test] + fn strips_markdown_links_and_truncates() { + let desc = "Create a guest user with access to a [subset of Workspace capabilities](https://support.google.com/a/answer/16558545). This feature is currently in Alpha. Please reach out to support if you are interested in enabling this feature."; + let result = truncate_description(desc, 120, true); + // After stripping the link, the sentence boundary should work. + assert!(result.contains("subset of Workspace capabilities.")); + assert!(!result.contains("https://")); + } + + #[test] + fn multibyte_safe() { + let desc = "Résumé création für Ñoño — a long description that should be safely truncated at word boundaries without panicking on multi-byte chars"; + let result = truncate_description(desc, 30, true); + assert!(result.ends_with('…') || result.chars().count() <= 30); + } + + #[test] + fn empty_and_whitespace() { + assert_eq!(truncate_description("", 100, true), ""); + assert_eq!(truncate_description(" ", 100, true), ""); + assert_eq!(truncate_description("", 0, true), ""); + } + + #[test] + fn test_strip_markdown_links() { + assert_eq!(strip_markdown_links("[text](http://example.com)"), "text"); + assert_eq!( + strip_markdown_links("Use [this link](http://a.com) and [that](http://b.com) too"), + "Use this link and that too" + ); + assert_eq!(strip_markdown_links("no links here"), "no links here"); + // Incomplete link syntax should be left alone + assert_eq!(strip_markdown_links("[broken"), "[broken"); + assert_eq!(strip_markdown_links("[text]no-parens"), "[text]no-parens"); + } + + #[test] + fn preserves_sentence_ending_at_limit() { + let desc = "Deletes a user."; + assert_eq!(truncate_description(desc, 15, true), "Deletes a user."); + } + + #[test] + fn does_not_cut_url_looking_periods() { + // Periods in URLs or abbreviations like "v1." shouldn't be treated as sentence ends + // unless followed by a space + let desc = "See the docs at developers.google.com for more details on this API endpoint"; + let result = truncate_description(desc, 50, true); + // Should truncate at word boundary, not at "developers." + assert!(result.ends_with('…')); + } + + #[test] + fn sentence_boundary_at_exact_limit() { + // Period falls exactly at the end of the prefix — should still detect it + let desc = "This is a complete sentence. And more text follows here."; + let result = truncate_description(desc, 28, true); + assert_eq!(result, "This is a complete sentence."); + } + + #[test] + fn zero_max_chars() { + assert_eq!(truncate_description("anything", 0, true), ""); + } + + #[test] + fn test_to_kebab_flag() { + // snake_case + assert_eq!(to_kebab_flag("page_token"), "page-token"); + assert_eq!(to_kebab_flag("user_id"), "user-id"); + assert_eq!(to_kebab_flag("min_start_time"), "min-start-time"); + assert_eq!(to_kebab_flag("a_b_c"), "a-b-c"); + // camelCase + assert_eq!(to_kebab_flag("pageToken"), "page-token"); + assert_eq!(to_kebab_flag("userId"), "user-id"); + assert_eq!(to_kebab_flag("minStartTime"), "min-start-time"); + assert_eq!(to_kebab_flag("eventTypeURI"), "event-type-u-r-i"); + // already kebab or simple + assert_eq!(to_kebab_flag("simple"), "simple"); + assert_eq!(to_kebab_flag("uuid"), "uuid"); + assert_eq!(to_kebab_flag(""), ""); + // Header-Case (HTTP header names — idempotency headers, custom + // headers — pass through to the flag builder as-is via the + // synthetic-parameter path). + assert_eq!(to_kebab_flag("Idempotency-Key"), "idempotency-key"); + assert_eq!(to_kebab_flag("X-Request-Id"), "x-request-id"); + assert_eq!(to_kebab_flag("Content-Type"), "content-type"); + // Defensive: doubled separators in mixed-case inputs collapse. + assert_eq!(to_kebab_flag("foo--bar"), "foo-bar"); + assert_eq!(to_kebab_flag("foo__bar"), "foo-bar"); + assert_eq!(to_kebab_flag("-leading-dash"), "leading-dash"); + } + + #[test] + fn test_to_screaming_snake() { + // camelCase → SCREAMING_SNAKE + assert_eq!(to_screaming_snake("gardenId"), "GARDEN_ID"); + assert_eq!(to_screaming_snake("pageToken"), "PAGE_TOKEN"); + // snake_case stays underscore-delimited and uppercases + assert_eq!(to_screaming_snake("min_start_time"), "MIN_START_TIME"); + // kebab inputs flatten the same way as camel + assert_eq!(to_screaming_snake("garden-id"), "GARDEN_ID"); + // single token + assert_eq!(to_screaming_snake("uuid"), "UUID"); + assert_eq!(to_screaming_snake(""), ""); + } + + // ------------------------------------------------------------------ + // sanitize_flag_name — FER-10430 parametrized table + // ------------------------------------------------------------------ + + #[test] + fn test_sanitize_flag_name_table() { + let cases: &[(&str, &str)] = &[ + ("id:in", "id-in"), + ("customer_group_id:in", "customer-group-id-in"), + ("date_created:min", "date-created-min"), + ("email:like", "email-like"), + ("customer_id", "customer-id"), + ("customerId", "customer-id"), + ("address.street", "address-street"), + ("tag[0]", "tag-0"), + ("customer{id}", "customer-id"), + ("q+filter", "q-filter"), + ("category,subcategory", "category-subcategory"), + ("na\u{00ef}ve", "naive"), // naïve → NFKD + ("from-date", "from-date"), // already valid + ("__proto__", "proto"), // leading/trailing trimmed + ]; + for (wire, expected) in cases { + let result = sanitize_flag_name(wire).unwrap_or_else(|e| { + panic!("sanitize_flag_name({wire:?}) returned Err: {e}") + }); + assert_eq!( + result, *expected, + "sanitize_flag_name({wire:?}): got {result:?}, expected {expected:?}", + ); + } + } + + #[test] + fn test_sanitize_flag_name_rejects_control_chars() { + let result = sanitize_flag_name("id\x00in"); + assert!(result.is_err(), "control char should be rejected"); + assert!(result.unwrap_err().contains("control character")); + } + + #[test] + fn test_sanitize_flag_name_rejects_whitespace() { + let result = sanitize_flag_name("id in"); + assert!(result.is_err(), "whitespace should be rejected"); + assert!(result.unwrap_err().contains("whitespace")); + } + + #[test] + fn test_sanitize_flag_name_rejects_cjk() { + let result = sanitize_flag_name("\u{5DF2}\u{8BFB}"); + assert!(result.is_err(), "CJK should be rejected"); + assert!(result.unwrap_err().contains("non-transliterable")); + } + + #[test] + fn test_sanitize_flag_name_idempotence() { + let cases = &["id:in", "customer_group_id:in", "address.street", "na\u{00ef}ve"]; + for wire in cases { + let first = sanitize_flag_name(wire).unwrap(); + let second = sanitize_flag_name(&first).unwrap(); + assert_eq!( + first, second, + "sanitize_flag_name should be idempotent for {wire:?}: first={first:?}, second={second:?}", + ); + } + } + + #[test] + fn test_sanitize_flag_name_empty_rejected() { + assert!(sanitize_flag_name("").is_err()); + } + + #[test] + fn test_sanitize_flag_name_strips_zero_width() { + // Zero-width space inside a name is silently stripped (invisible, + // so the adjacent letters merge). + let result = sanitize_flag_name("foo\u{200B}bar").unwrap(); + assert_eq!(result, "foobar"); + } +} diff --git a/src/user_agent.rs b/src/user_agent.rs new file mode 100644 index 0000000..46e9a85 --- /dev/null +++ b/src/user_agent.rs @@ -0,0 +1,103 @@ +//! Configuration for the consumer `User-Agent` suffix flag/env name. +//! +//! A generated CLI always identifies itself as `-cli/`. +//! A tool built on top of it can *append* its own product token either +//! with a global flag or a scoped env var, so the backend sees both +//! identities (e.g. `elevenlabs-cli/1.4.0 partner-app/3.1`). +//! +//! The flag's long name — and, by derivation, the env-var name — is +//! configurable at generation time via the CLI generator's +//! `userAgentSuffixFlag` custom config. When a customer does not set it, +//! the CLI defaults to `--user-agent-suffix` / `_USER_AGENT_SUFFIX`. +//! +//! The configured name is a single process-wide value set once at +//! startup from the generated `main.rs` (via +//! [`crate::app::CliApp::user_agent_suffix_flag`]). Every consumer — the +//! clap flag registration, the `--help`/`--schema` text, the env-var +//! lookup, and the parameter-collision guard — reads it back through +//! [`suffix_flag`] so they stay in agreement. + +use std::sync::OnceLock; + +/// Default long flag name when `userAgentSuffixFlag` is not configured. +/// Kept self-documenting (rather than an opaque brand) so most CLIs ship +/// with a clear knob. +pub const DEFAULT_SUFFIX_FLAG: &str = "user-agent-suffix"; + +/// Process-wide configured suffix flag name. Written at most once at +/// startup by [`set_suffix_flag`]; unset means [`DEFAULT_SUFFIX_FLAG`]. +static SUFFIX_FLAG: OnceLock = OnceLock::new(); + +/// Record the configured suffix flag name. Called once from the generated +/// `main.rs` builder chain. Blank / whitespace-only names are ignored so +/// the default still applies. Subsequent calls are no-ops (the value is +/// fixed for the process); this keeps behavior deterministic if a builder +/// is constructed more than once. +pub fn set_suffix_flag(name: &str) { + let trimmed = name.trim(); + if trimmed.is_empty() { + return; + } + let _ = SUFFIX_FLAG.set(trimmed.to_string()); +} + +/// The configured suffix flag's long name (without the leading `--`), +/// or [`DEFAULT_SUFFIX_FLAG`] when unset. +pub fn suffix_flag() -> &'static str { + SUFFIX_FLAG.get().map_or(DEFAULT_SUFFIX_FLAG, String::as_str) +} + +/// The env-var segment for the current suffix flag: an underscore prefix +/// plus the flag name uppercased with hyphens converted to underscores. +/// Combined with the CLI's `` prefix it yields the full env var, +/// e.g. flag `via` → `_VIA` → `_VIA`; the default `user-agent-suffix` +/// → `_USER_AGENT_SUFFIX` → `_USER_AGENT_SUFFIX`. +pub fn suffix_env_segment() -> String { + env_segment_for(suffix_flag()) +} + +/// Pure derivation of the env-var segment from a flag name. Extracted so +/// it can be unit-tested without touching the process-wide flag. +pub(crate) fn env_segment_for(flag: &str) -> String { + format!("_{}", flag.to_uppercase().replace('-', "_")) +} + +/// Whether a parameter-derived flag name would collide with the configured +/// suffix flag (and therefore must be mangled to avoid a clap conflict). +/// The default name is already covered by the built-in flag list, so this +/// only matters when a customer configures a custom name. +pub fn collides_with_suffix_flag(flag_name: &str) -> bool { + collides_with(flag_name, suffix_flag()) +} + +/// Pure collision check, extracted for unit testing. A parameter only +/// needs mangling when a *custom* suffix flag matches it — the default is +/// handled by the built-in reserved list. +pub(crate) fn collides_with(flag_name: &str, suffix_flag: &str) -> bool { + suffix_flag != DEFAULT_SUFFIX_FLAG && flag_name == suffix_flag +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_segment_default_matches_legacy_name() { + assert_eq!(env_segment_for(DEFAULT_SUFFIX_FLAG), "_USER_AGENT_SUFFIX"); + } + + #[test] + fn env_segment_uppercases_and_translates_dashes() { + assert_eq!(env_segment_for("via"), "_VIA"); + assert_eq!(env_segment_for("partner-tag"), "_PARTNER_TAG"); + } + + #[test] + fn collision_only_for_custom_flag() { + // Default name never reports a collision (built-ins handle it). + assert!(!collides_with("user-agent-suffix", DEFAULT_SUFFIX_FLAG)); + // A custom name collides only with an identically-named param. + assert!(collides_with("via", "via")); + assert!(!collides_with("other", "via")); + } +} diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 0000000..65b8be6 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,1000 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Shared input validation helpers. +//! +//! These functions harden CLI inputs against adversarial or accidentally +//! malformed values — especially important when the CLI is invoked by an +//! LLM agent rather than a human operator. + +use crate::error::CliError; +use std::path::{Path, PathBuf}; + +use crate::output::reject_dangerous_chars as reject_control_chars; + +/// Validates and resolves an output directory path. +/// +/// The only hard checks are null-byte / control-character rejection. The +/// path may be relative (resolved against CWD) or absolute, and may point +/// anywhere on the filesystem — the CLI does not sandbox where the user +/// asks it to write. If a caller needs to restrict writes, that is the +/// responsibility of the surrounding environment (agent/file-system +/// permissions), not this generated CLI. +/// +/// Returns the resolved path (canonicalized where the target or its +/// existing prefix exists) on success. +pub fn validate_safe_output_dir(dir: &str) -> Result { + reject_control_chars(dir, "--output-dir")?; + + let path = Path::new(dir); + + // Resolve relative paths against CWD; absolute paths are used as-is + // (`join` on an absolute path returns the absolute path unchanged). + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + let resolved = cwd.join(path); + + // If the directory already exists, canonicalize. Otherwise, canonicalize + // the longest existing prefix and append the remaining segments. + let canonical = if resolved.exists() { + resolved.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to resolve --output-dir '{dir}': {e}")) + })? + } else { + normalize_non_existing(&resolved)? + }; + + Ok(canonical) +} + +/// Validates that `dir` is a safe directory for reading files (e.g. `--dir` +/// in `script +push`). +/// +/// Similar to [`validate_safe_output_dir`] but also follows symlinks +/// safely and ensures the resolved path stays under CWD. +pub fn validate_safe_dir_path(dir: &str) -> Result { + reject_control_chars(dir, "--dir")?; + + let path = Path::new(dir); + + // "." is always safe (CWD itself) + if dir == "." { + return std::env::current_dir().map_err(|e| { + CliError::Validation(format!("Failed to determine current directory: {e}")) + }); + } + + if path.is_absolute() { + return Err(CliError::Validation(format!( + "--dir must be a relative path, got absolute path '{dir}'" + ))); + } + + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + let resolved = cwd.join(path); + + let canonical = resolved + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to resolve --dir '{dir}': {e}")))?; + + let canonical_cwd = cwd.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to canonicalize current directory: {e}")) + })?; + + if !canonical.starts_with(&canonical_cwd) { + return Err(CliError::Validation(format!( + "--dir '{dir}' resolves to '{}' which is outside the current directory", + canonical.display() + ))); + } + + Ok(canonical) +} + +/// Validates a `--output` (or otherwise write-side) file path. +/// +/// The path may be relative (resolved against CWD) or absolute and may +/// point anywhere on the filesystem — the CLI does not sandbox where the +/// user asks it to write. It rejects control characters, empty / dot-only +/// filenames, and paths whose parent directory does not exist (create it +/// first). The final component is left un-resolved so callers can open it +/// with `O_NOFOLLOW` and refuse to clobber a symlink target. +/// +/// # Returns a path with an *un-canonicalized basename* +/// +/// The returned [`PathBuf`] is `canonical_parent.join(basename)`. The +/// basename is preserved verbatim so that callers can open it with +/// `O_NOFOLLOW` and have the kernel refuse a final-component symlink +/// atomically. A full `canonicalize()` of the whole path would silently +/// resolve a basename symlink, opening a writeback primitive against the +/// symlink target. +/// +/// This means **callers MUST open the returned path with `O_NOFOLLOW`** +/// (or equivalent — see [`crate::openapi::executor`] `create_file_no_follow`). +/// A plain [`tokio::fs::File::open`] / [`std::fs::read`] on the returned +/// path will silently follow a basename symlink — re-introducing the +/// vulnerability this function was designed to prevent. +/// +/// # TOCTOU caveat +/// +/// Best-effort defence-in-depth against clobbering a symlink at the final +/// component. A local attacker with write access to a parent directory +/// could replace a path component between validation and the subsequent +/// I/O; race-free protection requires `openat2(RESOLVE_NO_SYMLINKS)` on +/// Linux or a per-component `openat(O_NOFOLLOW|O_DIRECTORY)` chain +/// elsewhere — tracked as a follow-up. +pub fn validate_safe_file_path(path_str: &str, flag_name: &str) -> Result { + reject_control_chars(path_str, flag_name)?; + + let path = Path::new(path_str); + let cwd = std::env::current_dir() + .map_err(|e| CliError::Validation(format!("Failed to determine current directory: {e}")))?; + + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + + // Reject empty / dot-only inputs explicitly so the user sees a clear + // "requires a filename" diagnostic rather than a confusing parent-lookup + // error (`cwd.join("")` / `cwd.join(".")` name the CWD itself, which has + // no meaningful basename to write to). + let trimmed = path_str.trim(); + if trimmed.is_empty() || trimmed == "." || trimmed == ".." { + return Err(CliError::Validation(format!( + "{flag_name} requires a filename, got '{path_str}'" + ))); + } + + // Separate the basename from the parent directory: we canonicalize the + // *parent* but leave the basename un-resolved. Callers that open the + // returned path with `O_NOFOLLOW` (see + // openapi::executor::create_file_no_follow) then rely on the kernel to + // refuse a final-component symlink atomically. A full `canonicalize()` + // of the whole path would silently resolve the basename symlink, opening + // a writeback primitive against its target. + // + // We REQUIRE the parent to already exist so it can be canonicalized + // (which is what produces the returned path). Callers must + // `mkdir -p ` first — the CLI does not create intermediate + // directories on the user's behalf. + let basename = resolved.file_name().ok_or_else(|| { + CliError::Validation(format!( + "{flag_name} '{path_str}' has no filename component" + )) + })?; + let parent = resolved.parent().ok_or_else(|| { + CliError::Validation(format!( + "{flag_name} '{path_str}' has no parent directory" + )) + })?; + if !parent.exists() { + return Err(CliError::Validation(format!( + "{flag_name} '{}' parent directory '{}' does not exist; create it first (e.g. `mkdir -p {}`)", + path_str, + parent.display(), + parent.display(), + ))); + } + let canonical_parent = parent.canonicalize().map_err(|e| { + CliError::Validation(format!("Failed to resolve {flag_name} '{path_str}': {e}")) + })?; + + Ok(canonical_parent.join(basename)) +} + + +// reject_control_chars is now a re-export from crate::output (see top of file) + +/// Resolves a path that may not exist yet by canonicalizing the existing +/// prefix and appending remaining components. +fn normalize_non_existing(path: &Path) -> Result { + let mut resolved = PathBuf::new(); + let mut remaining = Vec::new(); + + // Walk backwards until we find a component that exists + let mut current = path.to_path_buf(); + loop { + if current.exists() { + resolved = current + .canonicalize() + .map_err(|e| CliError::Validation(format!("Failed to canonicalize path: {e}")))?; + break; + } + if let Some(name) = current.file_name() { + remaining.push(name.to_os_string()); + } else { + // We've exhausted the path without finding an existing prefix + return Err(CliError::Validation(format!( + "Cannot resolve path '{}'", + path.display() + ))); + } + current = match current.parent() { + Some(p) => p.to_path_buf(), + None => break, + }; + } + + // Append remaining segments (in reverse since we collected them backwards) + for seg in remaining.into_iter().rev() { + resolved.push(seg); + } + + Ok(resolved) +} + +/// Characters to encode in a single URL path segment. All RFC 3986 §2.3 +/// unreserved characters (`A-Z a-z 0-9 - . _ ~`) are left unencoded; +/// everything else is percent-encoded. +use percent_encoding::{AsciiSet, CONTROLS}; +const PATH_SEGMENT: &AsciiSet = &CONTROLS + .add(b' ').add(b'!').add(b'"').add(b'#').add(b'$').add(b'%') + .add(b'&').add(b'\'').add(b'(').add(b')').add(b'*').add(b'+') + .add(b',').add(b'/').add(b':').add(b';').add(b'<') + .add(b'=').add(b'>').add(b'?').add(b'@').add(b'[').add(b'\\') + .add(b']').add(b'^').add(b'`').add(b'{').add(b'|').add(b'}'); + +/// Percent-encode a value for use as a single URL path segment (e.g., file ID, +/// calendar ID, message ID). All RFC 3986 §2.3 unreserved characters +/// (`A-Z a-z 0-9 - . _ ~`) are left unencoded. +pub fn encode_path_segment(s: &str) -> String { + use percent_encoding::utf8_percent_encode; + utf8_percent_encode(s, PATH_SEGMENT).to_string() +} + +/// Returns `true` when `segment` is a WHATWG dot-segment that would be +/// collapsed by `url::Url::parse()`. Encoding cannot prevent this — +/// `%2E` and `%2e` are also collapsed — so the caller must reject +/// rather than encode. +pub fn is_dot_segment(segment: &str) -> bool { + matches!(segment, "." | "..") +} + +// -- Query-component encoding ------------------------------------------------ +// +// The set below mirrors `QUERY_COMPONENT` in `src/openapi/executor.rs`. Per +// the architecture rule (`AGENTS.md` "Code Generation Model") asyncapi may +// not import from openapi, so the asyncapi executor calls this shared +// helper instead. The set is duplicated by design; if the openapi set +// changes, audit this one for parity. + +/// Percent-encode set for a query-string component (key or value). +/// +/// RFC 3986 unreserved characters (`A-Za-z0-9-_.~`) are left intact; the comma +/// is also kept literal so a form/no-explode array reads `ids=1,2` rather than +/// `ids=1%2C2`. Everything else — including space (`%20`, *not* the form +/// `+`), `|` (`%7C`), `&`, `=`, `#`, and `[` `]` — is percent-encoded. +const QUERY_COMPONENT: &AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'_') + .remove(b'.') + .remove(b'~') + .remove(b','); + +/// Percent-encode `s` for use as a URL query-string component (key or value). +/// +/// Encodes space as `%20` (not `+`), and all reserved characters that would +/// otherwise terminate or split the component (`&`, `=`, `#`, `+`, `/`, `?`, +/// control chars). RFC 3986 unreserved (`A-Za-z0-9-_.~`) and `,` pass through +/// unchanged. +pub fn encode_query_component(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, QUERY_COMPONENT).to_string() +} + +/// Percent-encode a value for use in URI path templates where `/` should stay +/// as a path separator (e.g., RFC 6570 `{+name}` expansions). +/// +/// Each path segment is encoded independently, then joined with `/`, so +/// dangerous characters like `#`/`?` are still escaped while hierarchical +/// resource names such as `projects/p/locations/l` remain readable. +pub fn encode_path_preserving_slashes(s: &str) -> String { + s.split('/') + .map(encode_path_segment) + .collect::>() + .join("/") +} + +/// Validate a multi-segment resource name (e.g., `spaces/ABC`, `subscriptions/123`). +/// Rejects path traversal, control characters, and URL-special characters including `%` +/// to prevent URL-encoded bypasses. Returns the validated name or an error. +pub fn validate_resource_name(s: &str) -> Result<&str, CliError> { + if s.is_empty() { + return Err(CliError::Validation( + "Resource name must not be empty".to_string(), + )); + } + if s.split('/').any(|seg| seg == ".." || seg == ".") { + return Err(CliError::Validation(format!( + "Resource name must not contain dot-segments ('.' or '..') : {s}" + ))); + } + if s.chars() + .any(|c| c == '\0' || c.is_control() || crate::output::is_dangerous_unicode(c)) + { + return Err(CliError::Validation(format!( + "Resource name contains invalid characters: {s}" + ))); + } + // Reject URL-special characters that could inject query params or fragments + if s.contains('?') || s.contains('#') { + return Err(CliError::Validation(format!( + "Resource name must not contain '?' or '#': {s}" + ))); + } + // Reject '%' to prevent URL-encoded bypasses (e.g. %2e%2e for ..) + if s.contains('%') { + return Err(CliError::Validation(format!( + "Resource name must not contain '%' (URL encoding bypass attempt): {s}" + ))); + } + Ok(s) +} + +/// Validate an API identifier (service name, version string) for use in +/// cache filenames and discovery URLs. Only alphanumeric characters, hyphens, +/// underscores, and dots are allowed to prevent path traversal and injection. +pub fn validate_api_identifier(s: &str) -> Result<&str, CliError> { + if s.is_empty() { + return Err(CliError::Validation( + "API identifier must not be empty".to_string(), + )); + } + if !s + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') + { + return Err(CliError::Validation(format!( + "API identifier contains invalid characters (only alphanumeric, '-', '_', '.' allowed): {s}" + ))); + } + Ok(s) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::fs; + use tempfile::tempdir; + + // --- validate_safe_output_dir --- + + #[test] + #[serial] + fn test_output_dir_relative_subdir() { + // Create a real temp dir and change into it for the test + let dir = tempdir().unwrap(); + // Canonicalize to handle macOS /var -> /private/var symlink + let canonical_dir = dir.path().canonicalize().unwrap(); + let sub = canonical_dir.join("output"); + fs::create_dir_all(&sub).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[cfg(unix)] + #[test] + #[serial] + fn test_output_dir_allows_symlink_target() { + // The CWD sandbox was removed: a symlink whose target lives outside + // CWD now resolves to that target instead of being rejected. + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let target_dir = tempdir().unwrap(); + let target_canonical = target_dir.path().canonicalize().unwrap(); + + let symlink_path = canonical_dir.join("link"); + std::os::unix::fs::symlink(&target_canonical, &symlink_path).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("link"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "got: {result:?}"); + assert_eq!(result.unwrap(), target_canonical); + } + + #[test] + #[serial] + fn test_output_dir_allows_traversal_target() { + // `../sibling` escapes CWD but is now accepted — writing where the + // user asks is their responsibility. + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("../sibling-out"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "got: {result:?}"); + } + + #[test] + fn test_output_dir_allows_absolute() { + // Absolute paths are no longer rejected. + let dir = tempdir().unwrap(); + let target = dir.path().canonicalize().unwrap().join("nested"); + let result = validate_safe_output_dir(target.to_str().unwrap()); + assert!(result.is_ok(), "got: {result:?}"); + } + + #[test] + fn test_output_dir_rejects_null_bytes() { + assert!(validate_safe_output_dir("foo\0bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_control_chars() { + assert!(validate_safe_output_dir("foo\x01bar").is_err()); + } + + #[test] + #[serial] + fn test_output_dir_non_existing_subdir() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_output_dir("new/nested/dir"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_ok(), + "expected Ok for non-existing subdir, got: {result:?}" + ); + } + + // --- validate_safe_dir_path --- + + #[test] + fn test_dir_path_cwd() { + assert!(validate_safe_dir_path(".").is_ok()); + } + + #[test] + #[serial] + fn test_dir_path_rejects_traversal() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_dir_path("../../etc"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err()); + } + + #[test] + fn test_dir_path_rejects_absolute() { + assert!(validate_safe_dir_path("/usr/local").is_err()); + } + + // --- reject_control_chars --- + + #[test] + fn test_reject_control_chars_clean() { + assert!(reject_control_chars("hello/world", "test").is_ok()); + } + + #[test] + fn test_reject_control_chars_tab() { + assert!(reject_control_chars("hello\tworld", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_newline() { + assert!(reject_control_chars("hello\nworld", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_del() { + assert!(reject_control_chars("hello\x7Fworld", "test").is_err()); + } + + // -- encode_path_segment -------------------------------------------------- + + #[test] + fn test_encode_path_segment_plain_id() { + assert_eq!(encode_path_segment("abc123"), "abc123"); + } + + #[test] + fn test_encode_path_segment_hyphenated_id() { + // Hyphens and underscores are unreserved (RFC 3986 §2.3) and common in + // resource IDs (UUIDs, slugs). They must not be percent-encoded. + assert_eq!(encode_path_segment("file-123"), "file-123"); + assert_eq!(encode_path_segment("my_resource_id"), "my_resource_id"); + assert_eq!( + encode_path_segment("550e8400-e29b-41d4-a716-446655440000"), + "550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_encode_path_segment_email() { + // Calendar IDs are often email addresses. `@` is a reserved + // character and must be encoded; `.` is unreserved per RFC 3986 + // §2.3 and must NOT be encoded. + let encoded = encode_path_segment("user@gmail.com"); + assert!(!encoded.contains('@'), "@ must be percent-encoded"); + assert!(encoded.contains('.'), ". is unreserved and must not be encoded"); + assert_eq!(encoded, "user%40gmail.com"); + } + + #[test] + fn test_encode_path_segment_dot_and_tilde_unreserved() { + // `.` and `~` are RFC 3986 §2.3 unreserved characters and must + // not be percent-encoded in path segments. + assert_eq!(encode_path_segment("file.txt"), "file.txt"); + assert_eq!(encode_path_segment("user~archive"), "user~archive"); + assert_eq!( + encode_path_segment("codex-test@agentmail.to"), + "codex-test%40agentmail.to" + ); + } + + #[test] + fn test_encode_path_segment_query_injection() { + // LLM might include query params in an ID by mistake + let encoded = encode_path_segment("fileid?fields=name"); + assert!(!encoded.contains('?')); + assert!(!encoded.contains('=')); + } + + #[test] + fn test_encode_path_segment_fragment_injection() { + let encoded = encode_path_segment("fileid#section"); + assert!(!encoded.contains('#')); + } + + #[test] + fn test_encode_path_segment_dot_segment_guard() { + // `.` and `~` pass through unencoded (RFC 3986 §2.3 unreserved). + assert_eq!(encode_path_segment("file.txt"), "file.txt"); + assert_eq!(encode_path_segment("..."), "..."); + assert_eq!(encode_path_segment(".hidden"), ".hidden"); + // Bare `.` and `..` also pass through from the encoder — WHATWG + // dot-segment rejection must happen at the caller, not here. + assert_eq!(encode_path_segment("."), "."); + assert_eq!(encode_path_segment(".."), ".."); + } + + #[test] + fn test_is_dot_segment() { + assert!(is_dot_segment(".")); + assert!(is_dot_segment("..")); + assert!(!is_dot_segment("...")); + assert!(!is_dot_segment(".hidden")); + assert!(!is_dot_segment("file.txt")); + assert!(!is_dot_segment("")); + } + + #[test] + fn test_encode_path_segment_path_traversal() { + // Encoding `/` makes traversal harmless — the path cannot escape + // the segment even though `.` is left unencoded (unreserved). + let encoded = encode_path_segment("../../etc/passwd"); + assert!(!encoded.contains('/'), "slashes must be encoded"); + assert_eq!(encoded, "..%2F..%2Fetc%2Fpasswd"); + } + + #[test] + fn test_encode_path_segment_unicode() { + // LLM might pass unicode characters + let encoded = encode_path_segment("日本語ID"); + assert!(!encoded.contains('日')); + } + + #[test] + fn test_encode_path_segment_spaces() { + let encoded = encode_path_segment("my file id"); + assert!(!encoded.contains(' ')); + } + + #[test] + fn test_encode_path_segment_already_encoded() { + // LLM might double-encode by passing pre-encoded values + let encoded = encode_path_segment("user%40gmail.com"); + // The % itself gets encoded to %25, so %40 becomes %2540 + // This prevents double-encoding issues at the HTTP layer + assert!(encoded.contains("%2540")); + } + + #[test] + fn test_encode_path_preserving_slashes_hierarchical_name() { + let encoded = encode_path_preserving_slashes("projects/p1/locations/us/topics/t1"); + assert_eq!(encoded, "projects/p1/locations/us/topics/t1"); + } + + #[test] + fn test_encode_path_preserving_slashes_escapes_reserved_chars() { + let encoded = encode_path_preserving_slashes("hash#1/child?x=y"); + assert_eq!(encoded, "hash%231/child%3Fx%3Dy"); + } + + #[test] + fn test_encode_path_preserving_slashes_spaces_and_unicode() { + let encoded = encode_path_preserving_slashes("タイムライン 1/列 A"); + assert!(!encoded.contains(' ')); + assert!(encoded.contains('/')); + } + + // -- validate_resource_name ----------------------------------------------- + + #[test] + fn test_validate_resource_name_valid() { + assert!(validate_resource_name("spaces/ABC123").is_ok()); + assert!(validate_resource_name("subscriptions/my-sub").is_ok()); + assert!(validate_resource_name("@default").is_ok()); + assert!(validate_resource_name("projects/p1/topics/t1").is_ok()); + } + + #[test] + fn test_validate_resource_name_traversal() { + assert!(validate_resource_name("../../etc/passwd").is_err()); + assert!(validate_resource_name("spaces/../other").is_err()); + assert!(validate_resource_name("..").is_err()); + } + + #[test] + fn test_validate_resource_name_single_dot() { + assert!(validate_resource_name(".").is_err()); + assert!(validate_resource_name("projects/./topics/t1").is_err()); + // Dots inside segment names are fine + assert!(validate_resource_name("file.txt").is_ok()); + assert!(validate_resource_name("user@mail.co").is_ok()); + } + + #[test] + fn test_validate_resource_name_control_chars() { + assert!(validate_resource_name("spaces/\0bad").is_err()); + assert!(validate_resource_name("spaces/\nbad").is_err()); + assert!(validate_resource_name("spaces/\rbad").is_err()); + assert!(validate_resource_name("spaces/\tbad").is_err()); + } + + #[test] + fn test_validate_resource_name_empty() { + assert!(validate_resource_name("").is_err()); + } + + #[test] + fn test_validate_resource_name_query_injection() { + // LLMs might append query strings or fragments to resource names + assert!(validate_resource_name("spaces/ABC?key=val").is_err()); + assert!(validate_resource_name("spaces/ABC#fragment").is_err()); + } + + #[test] + fn test_validate_resource_name_error_messages_are_clear() { + let err = validate_resource_name("").unwrap_err(); + assert!(err.to_string().contains("must not be empty")); + + let err = validate_resource_name("../bad").unwrap_err(); + assert!(err.to_string().contains("dot-segment")); + + let err = validate_resource_name("bad\0id").unwrap_err(); + assert!(err.to_string().contains("invalid characters")); + } + + #[test] + fn test_validate_resource_name_percent_bypass() { + // %2e%2e is .. + assert!(validate_resource_name("%2e%2e").is_err()); + assert!(validate_resource_name("spaces/%2e%2e/etc").is_err()); + // Just % should be rejected too + assert!(validate_resource_name("spaces/100%").is_err()); + } + + // --- reject_control_chars Unicode --- + + #[test] + fn test_reject_control_chars_zero_width_space() { + // U+200B zero-width space + assert!(reject_control_chars("foo\u{200B}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_bom() { + // U+FEFF byte-order mark / zero-width no-break space + assert!(reject_control_chars("foo\u{FEFF}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_rtl_override() { + // U+202E RIGHT-TO-LEFT OVERRIDE + assert!(reject_control_chars("foo\u{202E}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_unicode_line_separator() { + // U+2028 LINE SEPARATOR + assert!(reject_control_chars("foo\u{2028}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_paragraph_separator() { + // U+2029 PARAGRAPH SEPARATOR + assert!(reject_control_chars("foo\u{2029}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_zero_width_joiner() { + // U+200D ZERO WIDTH JOINER + assert!(reject_control_chars("foo\u{200D}bar", "test").is_err()); + } + + #[test] + fn test_reject_control_chars_normal_unicode_ok() { + // CJK, accented characters and emoji should pass + assert!(reject_control_chars("日本語", "test").is_ok()); + assert!(reject_control_chars("café", "test").is_ok()); + assert!(reject_control_chars("αβγ", "test").is_ok()); + } + + // --- path validator Unicode (via validate_safe_output_dir) --- + + #[test] + fn test_output_dir_rejects_zero_width_chars() { + // U+200B in a path segment + assert!(validate_safe_output_dir("foo\u{200B}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_rtl_override() { + assert!(validate_safe_output_dir("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_output_dir_rejects_unicode_line_separator() { + assert!(validate_safe_output_dir("foo\u{2028}bar").is_err()); + } + + // --- validate_resource_name Unicode --- + + #[test] + fn test_validate_resource_name_zero_width_chars() { + // U+200B, U+200D, U+FEFF all rejected + assert!(validate_resource_name("foo\u{200B}bar").is_err()); + assert!(validate_resource_name("foo\u{200D}bar").is_err()); + assert!(validate_resource_name("foo\u{FEFF}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_unicode_line_seps() { + assert!(validate_resource_name("foo\u{2028}bar").is_err()); + assert!(validate_resource_name("foo\u{2029}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_rtl_override() { + assert!(validate_resource_name("foo\u{202E}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_bidi_embedding() { + // U+202A LEFT-TO-RIGHT EMBEDDING, U+202B RIGHT-TO-LEFT EMBEDDING + assert!(validate_resource_name("foo\u{202A}bar").is_err()); + assert!(validate_resource_name("foo\u{202B}bar").is_err()); + } + + #[test] + fn test_validate_resource_name_homoglyphs_pass_through() { + // Cyrillic lookalikes are intentionally allowed (homoglyph detection + // is out of scope for this validator — see validate_resource_name docs). + assert!(validate_resource_name("spaces/ΑΒС").is_ok()); // Cyrillic С + } + + #[test] + fn test_validate_resource_name_overlong_accepted() { + // No length limit — documents current behaviour. + let long = "a".repeat(10_000); + assert!(validate_resource_name(&long).is_ok()); + } + + // --- validate_api_identifier --- + + #[test] + fn test_validate_api_identifier_valid() { + assert_eq!(validate_api_identifier("drive").unwrap(), "drive"); + assert_eq!(validate_api_identifier("v3").unwrap(), "v3"); + assert_eq!( + validate_api_identifier("directory_v1").unwrap(), + "directory_v1" + ); + assert_eq!( + validate_api_identifier("admin.reports_v1").unwrap(), + "admin.reports_v1" + ); + assert_eq!(validate_api_identifier("v2beta1").unwrap(), "v2beta1"); + } + + #[test] + fn test_validate_api_identifier_rejects_path_traversal() { + assert!(validate_api_identifier("../etc/passwd").is_err()); + assert!(validate_api_identifier("foo/../bar").is_err()); + } + + #[test] + fn test_validate_api_identifier_rejects_special_chars() { + assert!(validate_api_identifier("drive?key=val").is_err()); + assert!(validate_api_identifier("drive#frag").is_err()); + assert!(validate_api_identifier("drive%2f..").is_err()); + assert!(validate_api_identifier("v3 ").is_err()); + assert!(validate_api_identifier("v3\n").is_err()); + } + + #[test] + fn test_validate_api_identifier_empty() { + assert!(validate_api_identifier("").is_err()); + } + + // --- validate_safe_file_path --- + + #[test] + #[serial] + fn test_file_path_relative_is_ok() { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + fs::write(canonical_dir.join("test.txt"), "data").unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("test.txt", "--upload"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + } + + #[test] + #[serial] + fn test_file_path_allows_outside_cwd() { + // The CWD sandbox was removed: an --output whose parent exists but + // lives outside CWD is now accepted (the caller writes where asked). + let cwd_dir = tempdir().unwrap(); + let out_dir = tempdir().unwrap(); + let out_canonical = out_dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(cwd_dir.path().canonicalize().unwrap()).unwrap(); + + let target = out_canonical.join("out.txt"); + let result = validate_safe_file_path(target.to_str().unwrap(), "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "expected Ok, got: {result:?}"); + assert_eq!(result.unwrap(), out_canonical.join("out.txt")); + } + + #[test] + #[serial] + fn test_file_path_rejects_nonexistent_parent() { + // The parent directory must exist so it can be canonicalized. + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("does_not_exist/out.txt", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_err(), "non-existent parent should be rejected"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("does not exist"), + "error should mention the missing parent; got: {err}" + ); + } + + #[test] + fn test_file_path_rejects_control_chars() { + let result = validate_safe_file_path("file\x00.txt", "--output"); + assert!(result.is_err(), "null bytes should be rejected"); + } + + #[test] + #[serial] + fn test_file_path_allows_symlink_parent() { + // Intermediate (parent) symlinks that point outside CWD are now + // followed — the returned path lands under the symlink target. + // O_NOFOLLOW at open time still guards a FINAL-component symlink. + #[cfg(unix)] + { + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + let target_dir = tempdir().unwrap(); + let target_canonical = target_dir.path().canonicalize().unwrap(); + + let link_path = canonical_dir.join("escape"); + std::os::unix::fs::symlink(&target_canonical, &link_path).unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("escape/secret.txt", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!(result.is_ok(), "symlinked parent should be allowed: {result:?}"); + assert_eq!(result.unwrap(), target_canonical.join("secret.txt")); + } + } + + // -- encode_query_component ---------------------------------------------- + + #[test] + fn encode_query_component_encodes_space_as_percent20() { + assert_eq!(encode_query_component("a b"), "a%20b"); + } + + #[test] + fn encode_query_component_encodes_reserved_chars() { + assert_eq!(encode_query_component("a&b=c#d"), "a%26b%3Dc%23d"); + assert_eq!(encode_query_component("a+b"), "a%2Bb"); + assert_eq!(encode_query_component("a/b"), "a%2Fb"); + assert_eq!(encode_query_component("a?b"), "a%3Fb"); + assert_eq!(encode_query_component("a|b"), "a%7Cb"); + } + + #[test] + fn encode_query_component_unreserved_passes_through() { + // RFC 3986 unreserved set plus `,` — all should be literal. + assert_eq!(encode_query_component("Aa0-_.~,"), "Aa0-_.~,"); + } + + #[test] + fn encode_query_component_encodes_control_chars() { + let encoded = encode_query_component("a\x01\x1Fb"); + assert!(encoded.contains("%01")); + assert!(encoded.contains("%1F")); + assert!(!encoded.contains('\x01')); + } + + #[test] + fn encode_query_component_encodes_adversarial_agent_id() { + // The signature edge case from the acceptance criteria — agent_id + // with `/`, `?`, `&` must encode to `bad%2Fid%3Ffoo%26bar` so it + // cannot leak extra query parameters into the connect URL. + assert_eq!( + encode_query_component("bad/id?foo&bar"), + "bad%2Fid%3Ffoo%26bar", + ); + } + + #[test] + #[serial] + fn test_file_path_rejects_traversal_via_nonexistent_prefix() { + // `doesnt_exist/../../etc/passwd` is rejected because its parent + // directory (`doesnt_exist/../../etc`) cannot be reached — the + // leading `doesnt_exist` component does not exist, so the + // parent-must-exist check fires before any canonicalization. + let dir = tempdir().unwrap(); + let canonical_dir = dir.path().canonicalize().unwrap(); + + let saved_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(&canonical_dir).unwrap(); + + let result = validate_safe_file_path("doesnt_exist/../../etc/passwd", "--output"); + std::env::set_current_dir(&saved_cwd).unwrap(); + + assert!( + result.is_err(), + "traversal via non-existent prefix should be rejected" + ); + } +} diff --git a/src/websocket/auth.rs b/src/websocket/auth.rs new file mode 100644 index 0000000..9d448a6 --- /dev/null +++ b/src/websocket/auth.rs @@ -0,0 +1,533 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket authentication: query-param, header, and first-message +//! variants. Each variant takes an [`AuthCredentialSource`] directly — the +//! WS path deliberately bypasses [`AuthProvider`](crate::auth::AuthProvider) +//! (which is shaped around `reqwest::RequestBuilder`); see +//! `docs/adr/0001-auth-provider-no-cred-extraction.md`. + +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use secrecy::ExposeSecret; +use serde_json::Value; + +use crate::auth::AuthCredentialSource; +use crate::error::CliError; + +/// Percent-encoding set for query-string components: encode everything that +/// is not in the application/x-www-form-urlencoded "safe" set, plus the +/// reserved characters that would otherwise terminate the value (`&`, `=`, +/// `#`, `+`, ` `, `/`, `?`). Mirrors the `url::form_urlencoded` set without +/// adding `url` as a direct dep. +const QUERY_VALUE: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'&') + .add(b'+') + .add(b'/') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + +/// Where the WS handshake / first frame puts the credential. +/// +/// Variants take an [`AuthCredentialSource`] directly rather than a +/// resolved string so the same `cli > env > file` precedence patterns +/// users already configure for the HTTP path work without extra plumbing. +/// +/// # `AuthCredentialSource::Cli` footgun +/// +/// `AuthCredentialSource::cli("token")` is bound to a clap argument and +/// resolves to `None` until [`AuthCredentialSource::finalize`] is called +/// against the parsed matches. The HTTP path runs `finalize` automatically +/// inside `CliApp::run`; the WS path is invoked from a custom-command +/// handler that does *not* go through that finalize step. If you want +/// CLI-bound creds, either: +/// +/// - prefer `AuthCredentialSource::from_env(...)` so the same scheme used +/// by `auth_scheme_env` Just Works; +/// - or call `source = source.finalize(matches)` yourself before passing +/// the source into `WsAuth::*`. +/// +/// Missing creds surface as `CliError::Auth` so the failure mode is loud, +/// not silent. +pub enum WsAuth { + /// Append the credential as a query parameter on the connect URL. + /// Example: ElevenLabs `tts/stream-input?authorization=`. + QueryParam(String, AuthCredentialSource), + /// Send the credential as an HTTP header on the WS upgrade request. + /// Example: standard `xi-api-key: ` on ElevenLabs convai. + /// + /// # Header-value prefixes (footgun) + /// + /// The source's resolved value becomes the *entire* header value. + /// Deepgram requires `Authorization: Token ` — the literal word + /// `Token` is part of the value, NOT a scheme the library prepends. + /// **Prefer the convenience constructors [`WsAuth::bearer`] / + /// [`WsAuth::token`]** rather than baking the prefix into a literal + /// or closure by hand; they're auditable in one place and impossible + /// to misspell. + Header(String, AuthCredentialSource), + /// Send multiple HTTP headers on the WS upgrade request. Use when the + /// API requires more than one header on the handshake — for example, + /// OpenAI Realtime needs both `Authorization: Bearer ` AND + /// `OpenAI-Beta: realtime=v1`. Each pair is validated against the + /// WS-protocol reserved-header deny-list and each source must + /// resolve to a non-empty value. + Headers(Vec<(String, AuthCredentialSource)>), + /// Merge the credential into the *first* outbound JSON frame as the + /// named field. Example: ElevenLabs TTS `stream-input` requires + /// `{"xi_api_key": "", ...}` as the first text frame. + FirstMessage(String, AuthCredentialSource), + /// No auth (anonymous connection, or auth handled by the customer + /// outside this module). + None, +} + +impl WsAuth { + /// `Authorization: Bearer ` convenience. Prepends the literal + /// `Bearer ` to the resolved credential, so customers cannot + /// accidentally double-prefix or omit it. Use for OpenAI Realtime + /// and any RFC-6750 bearer-token API. + pub fn bearer(source: AuthCredentialSource) -> Self { + WsAuth::Header("Authorization".into(), prefix_source(source, "Bearer ")) + } + + /// `Authorization: Token ` convenience. Prepends the literal + /// `Token ` to the resolved credential. Use for Deepgram realtime — + /// Deepgram treats the word `Token` as part of the value, not a + /// scheme tungstenite prepends, so customers that miss this footgun + /// get a confusing 401 from the upgrade. + pub fn token(source: AuthCredentialSource) -> Self { + WsAuth::Header("Authorization".into(), prefix_source(source, "Token ")) + } + + /// Apply auth to the URL and header list before the handshake. + /// + /// For [`WsAuth::QueryParam`] this appends `?key=value` (or `&key=value`) + /// to `url`. For [`WsAuth::Header`] it pushes `(name, value)` onto + /// `headers`. For [`WsAuth::FirstMessage`] and [`WsAuth::None`] it's + /// a no-op — `FirstMessage` is applied by [`Self::merge_into_first_message`] + /// before the first send. + /// + /// Returns an error if the credential is required (i.e. variant is + /// not `None`) but the source resolves to `None` — that's almost + /// certainly a misconfiguration the user should see immediately. + pub fn apply_to_url_and_headers( + &self, + url: &mut String, + headers: &mut Vec<(String, String)>, + ) -> Result<(), CliError> { + match self { + WsAuth::QueryParam(key, source) => { + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for query param `{key}` is unset" + )) + })?; + append_query_param(url, key, secret.expose_secret()); + Ok(()) + } + WsAuth::Header(name, source) => { + apply_single_header(name, source, headers) + } + WsAuth::Headers(pairs) => { + for (name, source) in pairs { + apply_single_header(name, source, headers)?; + } + Ok(()) + } + WsAuth::FirstMessage(_, _) | WsAuth::None => Ok(()), + } + } + + /// Merge the credential into the first outbound JSON frame. + /// + /// Used only for [`WsAuth::FirstMessage`]; other variants are a no-op. + /// The frame must be a JSON object — merging a top-level field into a + /// non-object value is a misconfiguration and surfaces as `Validation`. + pub fn merge_into_first_message(&self, msg: &mut Value) -> Result<(), CliError> { + if let WsAuth::FirstMessage(field, source) = self { + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for first-message field `{field}` is unset" + )) + })?; + // Pre-compute the type name string so the error closure + // doesn't borrow `msg` while `as_object_mut` already holds a + // mutable borrow. + let observed = type_name(msg); + let obj = msg.as_object_mut().ok_or_else(|| { + CliError::Validation(format!( + "WebSocket auth: first message must be a JSON object to inject `{field}` \ + (got {observed})" + )) + })?; + obj.insert(field.clone(), Value::String(secret.expose_secret().to_string())); + } + Ok(()) + } +} + +/// Shared body for `Header` / `Headers` application — validates the name +/// against the reserved-handshake-header deny-list and resolves the source. +fn apply_single_header( + name: &str, + source: &AuthCredentialSource, + headers: &mut Vec<(String, String)>, +) -> Result<(), CliError> { + // Reject WS-protocol headers — letting a customer set `Host`, + // `Upgrade`, `Connection`, or `Sec-WebSocket-*` would silently + // clobber the auto-generated values from `IntoClientRequest` and + // produce a confusing handshake failure. Fail loudly. + if is_reserved_handshake_header(name) { + return Err(CliError::Validation(format!( + "WebSocket auth: header `{name}` is a WS-protocol \ + header and cannot be set via WsAuth — the handshake \ + machinery sets it automatically" + ))); + } + let secret = source.resolve().ok_or_else(|| { + CliError::Auth(format!( + "WebSocket auth: credential for header `{name}` is unset" + )) + })?; + headers.push((name.to_string(), secret.expose_secret().to_string())); + Ok(()) +} + +/// Append `?key=value` (or `&key=value` if a `?` is already present) to a +/// URL string. Percent-encodes the value so credentials with `&`, `=`, or +/// other URL-special characters survive the round-trip intact. +fn append_query_param(url: &mut String, key: &str, value: &str) { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str(&utf8_percent_encode(key, QUERY_VALUE).to_string()); + url.push('='); + url.push_str(&utf8_percent_encode(value, QUERY_VALUE).to_string()); +} + +/// Wrap `source` so its resolved value gets `prefix` prepended. +/// `AuthCredentialSource` derives `Clone`, so the move-closure can keep +/// re-resolving each request without consuming the original source. +fn prefix_source(source: AuthCredentialSource, prefix: &'static str) -> AuthCredentialSource { + AuthCredentialSource::closure(move || { + source + .resolve() + .map(|s| format!("{prefix}{}", s.expose_secret())) + }) +} + +/// Names of HTTP headers the WS handshake machinery sets itself. Setting +/// any of them via `WsAuth::Header` would either clobber the correct value +/// or get clobbered by tungstenite — both end in a confusing handshake +/// failure. Reject up front. +fn is_reserved_handshake_header(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.as_str(), + "host" + | "upgrade" + | "connection" + | "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-extensions" + | "sec-websocket-protocol" + | "sec-websocket-accept" + ) +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn literal(v: &str) -> AuthCredentialSource { + AuthCredentialSource::literal(v) + } + + #[test] + fn query_param_appends_to_clean_url() { + let mut url = "wss://api.example.com/v1/socket".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("authorization".into(), literal("bearer-token")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert!(url.starts_with("wss://api.example.com/v1/socket?")); + assert!(url.contains("authorization=bearer-token")); + assert!(headers.is_empty()); + } + + #[test] + fn query_param_appends_with_ampersand_when_query_present() { + let mut url = "wss://api.example.com/v1/socket?agent_id=abc".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("authorization".into(), literal("tok")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/v1/socket?agent_id=abc&authorization=tok"); + } + + #[test] + fn query_param_percent_encodes_special_chars() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::QueryParam("token".into(), literal("a&b=c d")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + // Percent-encoded: & → %26, = → %3D, space → %20 (we encode all + // reserved characters consistently rather than using application/ + // x-www-form-urlencoded's `+` for space — wss:// query strings + // tend to round-trip the percent form more reliably across libs.) + assert!(url.contains("token=a%26b%3Dc%20d"), "url: {url}"); + } + + #[test] + fn header_adds_to_header_list_does_not_touch_url() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::Header("xi-api-key".into(), literal("sk-test")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert_eq!(headers, vec![("xi-api-key".to_string(), "sk-test".to_string())]); + } + + #[test] + fn first_message_is_noop_at_handshake() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::FirstMessage("xi_api_key".into(), literal("sk-fm")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert!(headers.is_empty()); + } + + #[test] + fn none_is_noop() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + WsAuth::None + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!(url, "wss://api.example.com/"); + assert!(headers.is_empty()); + } + + #[test] + fn missing_credential_for_header_surfaces_as_auth_error() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + // Empty literal resolves to None — same path as a missing env var. + let err = WsAuth::Header("xi-api-key".into(), literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + assert!(err.to_string().contains("xi-api-key")); + } + + #[test] + fn missing_credential_for_query_param_surfaces_as_auth_error() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::QueryParam("authorization".into(), literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + assert!(err.to_string().contains("authorization")); + } + + #[test] + fn first_message_merges_field_into_json_object() { + let mut msg = serde_json::json!({"text": "hello", "voice_settings": {"stability": 0.5}}); + WsAuth::FirstMessage("xi_api_key".into(), literal("sk-merged")) + .merge_into_first_message(&mut msg) + .unwrap(); + assert_eq!(msg["xi_api_key"], "sk-merged"); + assert_eq!(msg["text"], "hello"); + } + + #[test] + fn first_message_rejects_non_object() { + let mut msg = serde_json::json!(["not", "an", "object"]); + let err = WsAuth::FirstMessage("xi_api_key".into(), literal("sk")) + .merge_into_first_message(&mut msg) + .expect_err("array first frame should error"); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn first_message_missing_credential_errors() { + let mut msg = serde_json::json!({}); + let err = WsAuth::FirstMessage("xi_api_key".into(), literal("")) + .merge_into_first_message(&mut msg) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn header_rejects_ws_protocol_reserved_names() { + let mut url = "wss://api.example.com/".to_string(); + let mut headers = Vec::new(); + for reserved in &[ + "Host", + "host", + "Upgrade", + "Connection", + "Sec-WebSocket-Key", + "Sec-WebSocket-Version", + "Sec-WebSocket-Protocol", + ] { + let err = WsAuth::Header((*reserved).into(), literal("x")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err(reserved); + assert!(matches!(err, CliError::Validation(_)), + "reserved `{reserved}` should validation-error, got: {err:?}"); + } + // Sanity: a non-reserved name passes. + assert!(WsAuth::Header("X-My-Custom".into(), literal("x")) + .apply_to_url_and_headers(&mut url, &mut headers) + .is_ok()); + } + + #[test] + fn headers_variant_emits_all_pairs_in_order() { + let mut url = "wss://api.openai.com/v1/realtime?model=gpt-4o".to_string(); + let mut headers = Vec::new(); + WsAuth::Headers(vec![ + ( + "Authorization".into(), + literal("Bearer sk-openai-test"), + ), + ( + "OpenAI-Beta".into(), + literal("realtime=v1"), + ), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![ + ("Authorization".to_string(), "Bearer sk-openai-test".to_string()), + ("OpenAI-Beta".to_string(), "realtime=v1".to_string()), + ] + ); + // URL is unchanged. + assert_eq!(url, "wss://api.openai.com/v1/realtime?model=gpt-4o"); + } + + #[test] + fn headers_variant_rejects_reserved_names_per_pair() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::Headers(vec![ + ("X-Custom".into(), literal("ok")), + ("Upgrade".into(), literal("nope")), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("reserved header should error"); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn headers_variant_missing_credential_errors() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + let err = WsAuth::Headers(vec![ + ("Authorization".into(), literal("Bearer xyz")), + ("OpenAI-Beta".into(), literal("")), + ]) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("missing cred should error"); + assert!(matches!(err, CliError::Auth(_))); + // Auth-error message names the missing header. + assert!(err.to_string().contains("OpenAI-Beta")); + } + + #[test] + fn bearer_helper_prepends_literal_bearer_space() { + let mut url = "wss://api.openai.com/v1/realtime".to_string(); + let mut headers = Vec::new(); + WsAuth::bearer(literal("sk-openai-test")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Bearer sk-openai-test".to_string())] + ); + } + + #[test] + fn token_helper_prepends_literal_token_space() { + let mut url = "wss://api.deepgram.com/v1/listen".to_string(); + let mut headers = Vec::new(); + WsAuth::token(literal("dg_secret")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers, + vec![("Authorization".to_string(), "Token dg_secret".to_string())] + ); + } + + #[test] + fn bearer_helper_surfaces_missing_credential_loudly() { + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + // Empty literal source resolves to None — should bubble up as + // CliError::Auth like the underlying Header variant. + let err = WsAuth::bearer(literal("")) + .apply_to_url_and_headers(&mut url, &mut headers) + .expect_err("empty cred should error"); + assert!(matches!(err, CliError::Auth(_))); + } + + #[test] + fn token_helper_does_not_double_prefix_already_prefixed_value() { + // If a customer mistakenly passes "Token foo" to `WsAuth::token`, + // they get "Token Token foo" — documented surprise; we don't + // try to detect "already prefixed" since that would be fragile. + // This test is explicit so future refactors don't accidentally + // start stripping prefixes (which would also be wrong). + let mut url = "wss://x".to_string(); + let mut headers = Vec::new(); + WsAuth::token(literal("Token already-prefixed")) + .apply_to_url_and_headers(&mut url, &mut headers) + .unwrap(); + assert_eq!( + headers[0].1, + "Token Token already-prefixed", + "token() always prepends — by design" + ); + } + + #[test] + fn other_variants_skip_merge_into_first_message() { + let mut msg = serde_json::json!({"text": "hi"}); + WsAuth::Header("xi-api-key".into(), literal("k")) + .merge_into_first_message(&mut msg) + .unwrap(); + WsAuth::QueryParam("auth".into(), literal("k")) + .merge_into_first_message(&mut msg) + .unwrap(); + WsAuth::None.merge_into_first_message(&mut msg).unwrap(); + // No mutation expected from any of these variants. + assert_eq!(msg, serde_json::json!({"text": "hi"})); + } +} diff --git a/src/websocket/client.rs b/src/websocket/client.rs new file mode 100644 index 0000000..a31edef --- /dev/null +++ b/src/websocket/client.rs @@ -0,0 +1,782 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! `WebSocketClient` — async bidirectional WS client driven by an +//! [`OutputPipeline`](crate::formatter::OutputPipeline). See `mod.rs` +//! for the module-level overview. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::protocol::{frame::coding::CloseCode, CloseFrame, Message}; + +use crate::error::CliError; +use crate::formatter::OutputPipeline; +use crate::http::HttpConfig; + +use super::auth::WsAuth; +use super::error::{classify_close_frame, map_handshake_error, map_stream_error}; + +/// Disposition the autoresponder returns for an inbound frame. +/// +/// Splits the two cases that the older `Option` return type +/// collapsed into one (`Some(Value)` meant "reply with `Value` and elide +/// emit"). A frame the responder wants to capture but for which it has +/// nothing to send back used to require returning a benign empty object +/// `{}` — which was written to the wire on every captured frame. The +/// dedicated [`ResponderAction::Suppress`] variant elides emit without +/// sending anything. +#[derive(Debug, Clone)] +pub enum ResponderAction { + /// Send `Value` as an outbound text frame **and** elide the inbound + /// frame from stdout. Used for app-level ping/pong. + Reply(Value), + /// Elide the inbound frame from stdout, send nothing. Used when the + /// caller is buffering frames for later assembly (e.g. streamed + /// agent responses). + Suppress, +} + +/// Inbound-frame autoresponder. +/// +/// Called once per inbound JSON frame. The returned [`ResponderAction`] +/// controls what happens to the frame: +/// +/// - `Some(ResponderAction::Reply(v))` — send `v` as an outbound text +/// frame and elide the inbound frame from stdout. Useful for +/// application-level ping/pong where the inbound is protocol +/// overhead, not user-visible payload. +/// - `Some(ResponderAction::Suppress)` — elide the inbound frame from +/// stdout, send nothing. Useful when the caller is buffering frames +/// for later assembly (e.g. streamed agent responses printed once +/// the turn completes). +/// - `None` — let the inbound flow through to [`OutputPipeline::emit`]. +/// +/// Customer code defines the closure — application-level keepalive shapes +/// are API-specific (`{"type":"ping"}` vs `{"event":"ping"}` vs binary +/// frames) and have no cross-API standard. A minimal example: +/// +/// ```ignore +/// use std::sync::Arc; +/// use fern_cli_sdk::websocket::{AutoResponder, ResponderAction}; +/// let responder: AutoResponder = Arc::new(|frame| { +/// if frame.get("type")?.as_str()? == "ping" { +/// Some(ResponderAction::Reply(serde_json::json!({"type": "pong"}))) +/// } else { +/// None +/// } +/// }); +/// ``` +/// +/// # Stateful autoresponders +/// +/// The closure is `Fn`, not `FnMut`, because the recv loop borrows it by +/// shared reference. If you need state (counter, throttle, per-event-id +/// dedupe), reach for interior mutability: +/// +/// ```ignore +/// use std::sync::atomic::{AtomicU64, Ordering}; +/// let count = std::sync::Arc::new(AtomicU64::new(0)); +/// let count_inner = count.clone(); +/// let responder: AutoResponder = std::sync::Arc::new(move |frame| { +/// count_inner.fetch_add(1, Ordering::Relaxed); +/// /* ... */ +/// None +/// }); +/// ``` +/// +/// Naïve `let mut n = 0; Arc::new(move |f| { n += 1; ... })` fails to +/// compile — the compiler error points at the closure body, not the +/// trait bound, which is easy to misread. +pub type AutoResponder = Arc Option + Send + Sync>; + +/// Configuration for a single WS connection. +pub struct WsConfig { + /// Connect URL (`wss://...` for TLS, `ws://...` for plaintext mocks). + pub url: String, + /// Where the credential goes (query / header / first-message / none). + pub auth: WsAuth, + /// Optional autoresponder. See [`AutoResponder`]. + pub auto_responder: Option, + /// Output pipeline applied to each inbound frame the autoresponder + /// did *not* claim. Pass via [`OutputPipeline::from_matches`] from + /// the custom-command handler so `--format` (and future + /// `--jq`/`--fields`/`--template`) flow through automatically. + pub output_pipeline: OutputPipeline, + /// If true, forward stdin lines as outbound text frames. EOF on stdin + /// triggers a clean WS Close(1000) and exit 0. + pub stdin_input: bool, + /// Validate each stdin line as JSON before sending. Invalid lines are + /// written to stderr as a warning and dropped (the connection is *not* + /// terminated). Default `true`. Set false only when the wire protocol + /// is non-JSON. + pub stdin_validate_json: bool, + /// JSON keys to recursively elide from each inbound frame before + /// emitting. Use `vec!["audio_base_64".into()]` for ElevenLabs to + /// strip the base64 audio blobs that would otherwise flood a terminal. + pub strip_audio_keys: Vec, + /// Hint string woven into mid-stream / abnormal-close error messages. + /// The default points at the ElevenLabs missed-pong pattern; override + /// when wiring an API with a different common failure mode (e.g. + /// Deepgram: "check KeepAlive cadence and audio format/encoding"; + /// OpenAI Realtime: "session may have hit the 30-minute cap"). + pub abnormal_close_hint: String, +} + +impl WsConfig { + /// Build a minimal config with no auth and a default pipeline. Useful + /// for in-process mock tests; production callers always fill in the + /// auth + autoresponder + stdin fields. + pub fn new(url: impl Into) -> Self { + Self { + url: url.into(), + auth: WsAuth::None, + auto_responder: None, + output_pipeline: OutputPipeline::default(), + stdin_input: false, + stdin_validate_json: true, + strip_audio_keys: Vec::new(), + abnormal_close_hint: super::error::ABNORMAL_CLOSE_HINT.to_string(), + } + } +} + +/// A connected WS client ready to send and receive frames. +pub struct WebSocketClient { + stream: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + config: WsConfig, + /// Tracks whether [`WebSocketClient::send`] has run yet. Used to + /// merge [`WsAuth::FirstMessage`] into the first outbound frame. + first_send_done: bool, +} + +impl WebSocketClient { + /// Connect to a WS endpoint, applying auth and reading TLS knobs from + /// `http_config`. + /// + /// Honored in v1: + /// - `_CONNECT_TIMEOUT_SECS` — applied as a handshake deadline. + /// + /// Resolved but not yet wired to the tungstenite connector + /// (deferred — misconfigurations still surface as a `CliError` at + /// `resolve()` time, before the handshake is attempted): + /// - `_CA_BUNDLE` / `_EXTRA_CA_CERTS` / `SSL_CERT_FILE` + /// - `_INSECURE` / `_INSECURE_SKIP_VERIFY` + /// - `_PROXY` / `_NO_PROXY` + /// + /// Not applicable to streaming transports: + /// - `_TIMEOUT_SECS` — bounds total request lifetime for the + /// reqwest path; a streaming WS connection has no defined "total + /// lifetime" so the value is ignored here. Use + /// `_CONNECT_TIMEOUT_SECS` for the handshake deadline. + /// + /// Default trust roots come from whichever TLS backend the feature + /// gate selects (`native-tls` reads the OS keychain; `rustls` uses + /// Mozilla's bundled webpki roots). + pub async fn connect( + mut config: WsConfig, + http_config: &HttpConfig, + ) -> Result { + // Resolve transport config up front. Even though v1 doesn't yet + // translate CA bundle / insecure into a tungstenite Connector, + // calling resolve() surfaces a misconfigured CA path immediately + // rather than after a confusing TLS error during handshake. + let resolved = http_config.resolve()?; + + // Apply URL/header auth. FirstMessage is deferred to first send(). + let mut url = config.url.clone(); + let mut headers: Vec<(String, String)> = Vec::new(); + config.auth.apply_to_url_and_headers(&mut url, &mut headers)?; + + // Build the handshake request (WS control headers + User-Agent + + // auth headers). See `build_handshake_request`. + let request = build_handshake_request(&url, &headers, &http_config.user_agent())?; + + // Sync the URL on the WsConfig with what we actually connected to, + // so anything downstream that reads it (logging, error messages) + // reflects the post-auth-apply form. + config.url = url; + + // Connect, with optional handshake deadline. + let connect_fut = tokio_tungstenite::connect_async(request); + let connect_result = if let Some(deadline) = resolved.connect_timeout { + tokio::time::timeout(deadline, connect_fut).await.map_err(|_| { + CliError::Other(anyhow::anyhow!( + "WebSocket handshake timed out after {}s", + deadline.as_secs(), + )) + })? + } else { + connect_fut.await + }; + + let (stream, _response) = connect_result.map_err(map_handshake_error)?; + Ok(Self { + stream, + config, + first_send_done: false, + }) + } + + /// Send a JSON value as a WS text frame. Applies + /// [`WsAuth::FirstMessage`] merging on the very first send, then + /// becomes a plain serialize-and-send. + pub async fn send(&mut self, msg: &Value) -> Result<(), CliError> { + let mut to_send = msg.clone(); + if !self.first_send_done { + self.config.auth.merge_into_first_message(&mut to_send)?; + self.first_send_done = true; + } + let text = serde_json::to_string(&to_send).map_err(|e| { + CliError::Validation(format!("failed to serialize WS frame: {e}")) + })?; + let hint = self.config.abnormal_close_hint.clone(); + self.stream + .send(Message::Text(text)) + .await + .map_err(|e| map_stream_error(e, &hint)) + } + + /// Send raw bytes as a WS binary frame. + /// + /// Required by APIs that ship PCM audio on the wire (Deepgram realtime, + /// AssemblyAI v3 Universal-Streaming). Customers typically call this + /// from their own audio-capture loop (`cpal` mic, file reader, etc.) + /// rather than from the stdin path — stdin forwarding stays JSON-text + /// only in v1 (see ADR-0002 follow-ups). + /// + /// # `WsAuth::FirstMessage` interaction + /// + /// `FirstMessage` auth merges the credential into the first outbound + /// JSON frame. Binary frames have no JSON object to merge into, so + /// calling `send_binary` as the *very first* outbound when `FirstMessage` + /// auth is configured silently drops the credential. We error loudly + /// instead: send a JSON frame first (typically a per-API "configure + /// session" message that *should* carry the credential), then call + /// `send_binary` for audio chunks. + pub async fn send_binary(&mut self, bytes: Vec) -> Result<(), CliError> { + if !self.first_send_done && matches!(self.config.auth, WsAuth::FirstMessage(_, _)) { + return Err(CliError::Validation( + "WebSocket: send_binary called before any send() with WsAuth::FirstMessage \ + configured — the auth credential would never reach the server. Send your \ + session-init JSON frame via `send(...)` first; binary frames after." + .into(), + )); + } + let hint = self.config.abnormal_close_hint.clone(); + self.stream + .send(Message::Binary(bytes)) + .await + .map_err(|e| map_stream_error(e, &hint)) + } + + /// Run the recv loop until either `shutdown` fires or the server + /// closes the connection. On graceful shutdown / server `Close(1000)`, + /// returns `Ok(())`. Other terminations map per the matrix in + /// [`super::error`]. + /// + /// `shutdown` is intentionally a generic future rather than a + /// `tokio_util::sync::CancellationToken` — keeps the dep surface + /// small, and lets tests pass a `oneshot::Receiver` without dragging + /// the SIGINT machinery into unit tests. Production wires this to + /// [`tokio::signal::ctrl_c`] via [`Self::run_recv_loop`]. + pub async fn run_until_shutdown(self, shutdown: F) -> Result<(), CliError> + where + F: std::future::Future + Send + Unpin, + { + let WebSocketClient { + stream, + config, + first_send_done, + } = self; + let (mut sink, mut source) = stream.split(); + + let stdin_input = config.stdin_input; + let stdin_validate_json = config.stdin_validate_json; + let abnormal_hint = config.abnormal_close_hint.clone(); + // Keep the first-send bookkeeping live across the loop so the + // stdin branch can honor `WsAuth::FirstMessage` — without this, + // a customer combining `stdin_input = true` with `FirstMessage` + // auth would have the auth field silently dropped from the first + // outbound frame. (Bug surfaced by Devin Review on PR #53.) + let mut first_send_done = first_send_done; + + // Bounded channel: stdin reader → recv loop. Bound is 64; when + // full, the reader blocks on `tx.send`, propagating backpressure + // back through the OS pipe buffer to the user's writer side. + // The `_stdin_tx_keepalive` binding holds the sender alive when + // we're not spawning a reader — without it the rx would return + // `None` on first recv, which the select! arm interprets as + // EOF (= clean shutdown) and exits immediately. Combined with + // the `if stdin_input` guard below this is belt-and-braces. + let (stdin_tx, mut stdin_rx) = mpsc::channel::(64); + let _stdin_tx_keepalive; + let stdin_handle = if stdin_input { + _stdin_tx_keepalive = None; + Some(tokio::spawn(stdin_reader_task(stdin_tx, stdin_validate_json))) + } else { + _stdin_tx_keepalive = Some(stdin_tx); + None + }; + + // Use the owned `Stdout` rather than a `StdoutLock`. Holding a + // lock across the recv loop's await points blocks any other + // thread that tries to write to stdout — and `StdoutLock` isn't + // `Send`, so the future itself wouldn't be `Send` either, which + // breaks `tokio::spawn`. `Stdout::write_all` locks internally + // per call, which is the right granularity for our throughput. + let mut stdout = std::io::stdout(); + let pipeline = config.output_pipeline.clone(); + let auto_responder = config.auto_responder.clone(); + let strip_keys: Vec = config.strip_audio_keys.clone(); + + let mut shutdown = shutdown; + + let exit_reason: Result<(), CliError> = loop { + tokio::select! { + // Bias toward shutdown — if a Ctrl+C fires the same + // instant as a frame arrives, the user expects the close + // path to win. + biased; + _ = &mut shutdown => { + break Ok(()); + } + line = stdin_rx.recv(), if stdin_input => { + match line { + Some(text) => { + // If `WsAuth::FirstMessage` is configured and + // we haven't sent yet, parse → merge → re-serialize + // so the auth credential lands in the very first + // outbound frame. Lines after the first ship as-is + // (matching `WebSocketClient::send`'s contract that + // FirstMessage applies only once per connection). + let to_send = if !first_send_done + && matches!(config.auth, WsAuth::FirstMessage(_, _)) + { + match serde_json::from_str::(&text) { + Ok(mut v) => { + if let Err(e) = + config.auth.merge_into_first_message(&mut v) + { + break Err(e); + } + match serde_json::to_string(&v) { + Ok(s) => s, + Err(e) => { + break Err(CliError::Validation(format!( + "failed to re-serialize first stdin \ + frame after merging FirstMessage \ + auth: {e}" + ))); + } + } + } + Err(e) => { + // FirstMessage auth requires merging + // into a JSON object — a non-JSON first + // stdin line breaks the contract loudly + // rather than silently dropping creds. + break Err(CliError::Validation(format!( + "FirstMessage auth requires the first stdin \ + frame to be valid JSON (got parse error: \ + {e}). If your wire protocol allows non-JSON \ + frames, call `client.send(...)` once with \ + the auth-bearing frame before \ + `run_until_shutdown`." + ))); + } + } + } else { + text + }; + first_send_done = true; + if let Err(e) = sink.send(Message::Text(to_send)).await { + break Err(map_stream_error(e, &abnormal_hint)); + } + } + None => { + // stdin EOF — clean exit per resolution sheet. + // The Close(1000) frame is sent after the loop + // unwinds (`exit_reason.is_ok()` branch below). + break Ok(()); + } + } + } + msg = source.next() => { + let result = handle_inbound( + msg, + &mut sink, + &auto_responder, + &pipeline, + &strip_keys, + &mut stdout, + &abnormal_hint, + ).await; + match result { + FrameDisposition::Continue => continue, + FrameDisposition::Stop(r) => break r, + } + } + } + }; + + // Send Close(1000) on graceful exit. We swallow the error from + // close() because the connection may already be closed (server + // initiated the close, network is gone, etc.) — `exit_reason` + // is the authoritative outcome. + // + // Note: when the server initiated the close, tungstenite has + // already queued the echo internally before our `Message::Close` + // reaches `sink.send`. Tungstenite's close-state machine treats + // user-side `send(Close)` as a no-op outside the Active state, + // so this is *not* a double-frame on the wire — just a wasted + // method call. Cheap and keeps the source readable. + if exit_reason.is_ok() { + let _ = tokio::time::timeout( + Duration::from_secs(2), + sink.send(Message::Close(Some(CloseFrame { + code: CloseCode::Normal, + reason: "".into(), + }))), + ) + .await; + } + + // Abort the stdin reader task. NOTE: `abort()` does NOT unwind a + // blocking read inside `tokio::io::stdin()` — the underlying + // blocking thread continues until the OS hands it a line or EOF. + // In `run_recv_loop` this is fine because the process is about + // to exit and the OS reclaims everything. Future stdin-driven + // *tests* will need a fake stdin (e.g. a custom `AsyncRead` + // injected via a future `WsConfig.stdin_source` field) to avoid + // leaking a blocking thread per test. + if let Some(handle) = stdin_handle { + handle.abort(); + } + + exit_reason + } + + /// Convenience wrapper that runs the recv loop until either + /// [`tokio::signal::ctrl_c`] fires or the server closes. + pub async fn run_recv_loop(self) -> Result<(), CliError> { + // Wrap the signal future so its concrete unit-typed shape lines + // up with the `Future + Unpin` bound on + // `run_until_shutdown`. Box the future to satisfy Unpin without + // requiring callers to pin manually. + let shutdown = Box::pin(async { + let _ = tokio::signal::ctrl_c().await; + }); + self.run_until_shutdown(shutdown).await + } +} + +enum FrameDisposition { + Continue, + Stop(Result<(), CliError>), +} + +/// Single-frame handler — invoked once per item from the WS source. +/// Returns whether the loop should keep going or break with a result. +async fn handle_inbound( + msg: Option>, + sink: &mut futures_util::stream::SplitSink< + tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + Message, + >, + auto_responder: &Option, + pipeline: &OutputPipeline, + strip_keys: &[String], + stdout: &mut std::io::Stdout, + abnormal_hint: &str, +) -> FrameDisposition { + match msg { + None => FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "WebSocket stream ended without a close frame — {abnormal_hint}" + )))), + Some(Err(e)) => FrameDisposition::Stop(Err(map_stream_error(e, abnormal_hint))), + Some(Ok(Message::Close(frame))) => { + FrameDisposition::Stop(classify_close_frame(frame.as_ref(), abnormal_hint)) + } + // WS protocol-level Ping/Pong are auto-handled by tungstenite; we + // never see them as user-payload. Frame is also internal. None of + // them should emit to stdout. + Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => { + FrameDisposition::Continue + } + Some(Ok(Message::Binary(b))) => { + // v1: inbound binary frames are not emitted to stdout + // (ElevenLabs / OpenAI use JSON text; Deepgram / AssemblyAI + // send JSON inbound and only accept binary outbound). Warn + // visibly so a customer hitting an API that *does* stream + // binary back (some Deepgram protobuf configs, some OpenAI + // tool-call audio paths) knows their stream produced + // unprintable bytes — silence would look like a hung pipe. + eprintln!( + "warning: dropped {}-byte inbound WebSocket binary frame \ + (v1 does not emit binary inbound; plumb a handler via \ + WsConfig in a future release if your API needs this)", + b.len(), + ); + FrameDisposition::Continue + } + Some(Ok(Message::Text(text))) => { + // Parse as JSON. If parsing fails, treat as transport-level + // garbage from the server — surface it. + let value: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(e) => { + return FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "WebSocket received unparseable JSON: {e}: {}", + truncate(&text, 200), + )))); + } + }; + + // Autoresponder first: if it claims the frame, dispatch on + // the requested action and elide emit either way. + if let Some(responder) = auto_responder { + match responder(&value) { + Some(ResponderAction::Reply(reply)) => { + let reply_text = match serde_json::to_string(&reply) { + Ok(s) => s, + Err(e) => { + return FrameDisposition::Stop(Err(CliError::Other( + anyhow::anyhow!( + "autoresponder produced unserializable JSON: {e}" + ), + ))); + } + }; + if let Err(e) = sink.send(Message::Text(reply_text)).await { + return FrameDisposition::Stop(Err(map_stream_error( + e, + abnormal_hint, + ))); + } + return FrameDisposition::Continue; + } + Some(ResponderAction::Suppress) => { + return FrameDisposition::Continue; + } + None => {} + } + } + + // Strip audio-shaped keys before emit (recursive). + let to_emit = if strip_keys.is_empty() { + value + } else { + let mut v = value; + strip_keys_recursive(&mut v, strip_keys); + v + }; + + // Emit through the pipeline. `paginated=true` so each frame + // emits as compact NDJSON (one object per line). + if let Err(e) = pipeline.emit(stdout, &to_emit, true, false) { + return FrameDisposition::Stop(Err(CliError::Other(anyhow::anyhow!( + "failed to emit WebSocket frame: {e}" + )))); + } + FrameDisposition::Continue + } + } +} + +/// Recursively remove keys whose name matches any entry in `keys`. Walks +/// objects and arrays in place. Linear in the JSON value's node count. +fn strip_keys_recursive(value: &mut Value, keys: &[String]) { + match value { + Value::Object(map) => { + for k in keys { + map.remove(k); + } + for (_, v) in map.iter_mut() { + strip_keys_recursive(v, keys); + } + } + Value::Array(arr) => { + for v in arr.iter_mut() { + strip_keys_recursive(v, keys); + } + } + _ => {} + } +} + +/// Stdin reader task: pushes validated lines onto the bounded sender. +/// When stdin EOFs, drops the sender so the main loop sees `None` on +/// its next `recv()` and exits cleanly. Blank lines silently skipped; +/// invalid JSON warned to stderr and dropped (connection stays up). +async fn stdin_reader_task(tx: mpsc::Sender, validate_json: bool) { + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + loop { + match reader.next_line().await { + Ok(Some(line)) => { + if line.trim().is_empty() { + continue; + } + if validate_json { + if let Err(e) = serde_json::from_str::(&line) { + eprintln!( + "warning: stdin line is not valid JSON, dropping: {} ({})", + truncate(&line, 80), + e, + ); + continue; + } + } + if tx.send(line).await.is_err() { + // Receiver dropped — the recv loop has exited. Stop. + return; + } + } + Ok(None) => return, // EOF — drop tx by returning + Err(e) => { + eprintln!("warning: stdin read error: {e}"); + return; + } + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +/// Build the WebSocket handshake request: the mandatory WS control headers +/// (`Sec-WebSocket-Key`/`Version`/`Upgrade`, from `IntoClientRequest`), then +/// the CLI's `User-Agent`, then any auth headers layered on top. +/// +/// The `User-Agent` is set here so realtime traffic is attributable the same +/// way HTTP traffic is (see [`HttpConfig::user_agent`]) — `into_client_request` +/// does not set one, so without this WebSocket CLIs would be invisible to +/// backend analytics. It is applied before the auth headers so an explicit +/// auth-supplied `User-Agent` (unusual) can still override it. A `user_agent` +/// that is not valid header content is skipped rather than failing the +/// handshake. +fn build_handshake_request( + url: &str, + headers: &[(String, String)], + user_agent: &str, +) -> Result { + let uri: tokio_tungstenite::tungstenite::http::Uri = url + .parse() + .map_err(|e| CliError::Validation(format!("invalid WebSocket URL `{url}`: {e}")))?; + let mut request = uri.into_client_request().map_err(map_handshake_error)?; + + if let Ok(value) = HeaderValue::from_str(user_agent) { + request.headers_mut().insert( + tokio_tungstenite::tungstenite::http::header::USER_AGENT, + value, + ); + } + + for (name, value) in headers { + let header_value = HeaderValue::from_str(value).map_err(|e| { + CliError::Validation(format!( + "WebSocket header `{name}` contains invalid characters: {e}" + )) + })?; + let header_name: tokio_tungstenite::tungstenite::http::HeaderName = + name.parse().map_err(|e| { + CliError::Validation(format!("invalid WebSocket header name `{name}`: {e}")) + })?; + request.headers_mut().insert(header_name, header_value); + } + + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_tungstenite::tungstenite::http::header::USER_AGENT; + + #[test] + fn handshake_request_sets_user_agent() { + let request = + build_handshake_request("wss://example.com/socket", &[], "elevenlabs-cli/1.4.0") + .expect("request builds"); + assert_eq!( + request.headers().get(USER_AGENT).map(|v| v.to_str().unwrap()), + Some("elevenlabs-cli/1.4.0"), + ); + } + + #[test] + fn handshake_request_auth_header_can_override_user_agent() { + // An explicit auth-supplied User-Agent wins over the default, since + // auth headers are layered after the CLI identity. + let headers = vec![("user-agent".to_string(), "custom/9.9".to_string())]; + let request = + build_handshake_request("wss://example.com/socket", &headers, "elevenlabs-cli/1.4.0") + .expect("request builds"); + assert_eq!( + request.headers().get(USER_AGENT).map(|v| v.to_str().unwrap()), + Some("custom/9.9"), + ); + } + + #[test] + fn handshake_request_skips_invalid_user_agent() { + // A malformed User-Agent is dropped rather than failing the handshake; + // the other required WS headers are still present. + let request = build_handshake_request("wss://example.com/socket", &[], "bad\nvalue") + .expect("request builds"); + assert!(request.headers().get(USER_AGENT).is_none()); + assert!(request + .headers() + .contains_key(tokio_tungstenite::tungstenite::http::header::SEC_WEBSOCKET_KEY)); + } + + #[test] + fn strip_keys_removes_top_level_and_nested() { + let mut value = serde_json::json!({ + "audio_base_64": "AAAA...", + "text": "hello", + "agent_response": { + "audio_base_64": "BBBB...", + "transcript": "world", + }, + "items": [ + {"audio_base_64": "CCCC...", "id": 1}, + {"audio_base_64": "DDDD...", "id": 2}, + ], + }); + strip_keys_recursive(&mut value, &["audio_base_64".to_string()]); + assert!(value.get("audio_base_64").is_none()); + assert_eq!(value["text"], "hello"); + assert!(value["agent_response"].get("audio_base_64").is_none()); + assert_eq!(value["agent_response"]["transcript"], "world"); + assert!(value["items"][0].get("audio_base_64").is_none()); + assert!(value["items"][1].get("audio_base_64").is_none()); + assert_eq!(value["items"][0]["id"], 1); + } + + #[test] + fn strip_keys_noop_when_keys_absent() { + let mut value = serde_json::json!({"text": "hi", "n": 1}); + strip_keys_recursive(&mut value, &["audio_base_64".to_string()]); + assert_eq!(value, serde_json::json!({"text": "hi", "n": 1})); + } +} diff --git a/src/websocket/error.rs b/src/websocket/error.rs new file mode 100644 index 0000000..231c95c --- /dev/null +++ b/src/websocket/error.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket failure → [`CliError`] mapping. +//! +//! # The matrix (v1) +//! +//! | Phase | Failure mode | `CliError` | Exit | +//! |---|---|---|---| +//! | handshake | DNS / TCP refused / reset | `Other` | 5 | +//! | handshake | TLS cert error | `Other` | 5 | +//! | handshake | 401 / 403 Upgrade rejected | `Auth` | 2 | +//! | handshake | 404 / wrong URL | `Discovery` | 4 | +//! | handshake | 5xx | `Api { code, .. }` | 1 | +//! | mid-stream | server `Close(1000)` Normal Closure | `Ok(())` | **0** | +//! | mid-stream | server `Close(1001..=1015)` abnormal | `Other` (hint included) | 5 | +//! | mid-stream | TCP drop / read timeout / inactivity | `Other` (hint included) | 5 | +//! | local | bad URL given to [`WsConfig::url`](super::WsConfig) | `Validation` | 3 | +//! | local | unparseable JSON from server | `Other` | 5 | +//! +//! The abnormal-close hint nudges users toward the most common failure +//! mode — typically a missed application-level keepalive reply. Each +//! customer overrides it on their [`super::WsConfig`] with API-specific +//! guidance. + +use tokio_tungstenite::tungstenite; + +use crate::error::CliError; + +/// Default hint appended to abnormal-close errors. API-neutral by +/// design — it's the message a customer of *any* WS-using CLI should +/// understand. Customer code overrides +/// [`super::WsConfig::abnormal_close_hint`] with API-specific guidance. +pub const ABNORMAL_CLOSE_HINT: &str = + "connection ended abnormally; check auth, network, and the API's keepalive/timeout requirements"; + +/// Map a `tungstenite::Error` raised during the handshake phase to a +/// [`CliError`] following the matrix above. Public so an external caller +/// implementing its own handshake wrapper (e.g. for unit-testing the +/// matrix in isolation) can reuse the mapping. +pub fn map_handshake_error(err: tungstenite::Error) -> CliError { + use tungstenite::Error as TE; + + match err { + TE::Http(response) => { + // The HTTP-status-bearing handshake failure: the server + // accepted the TCP connection but rejected the Upgrade. + let status = response.status().as_u16(); + // Best-effort body capture for the error message. Tungstenite + // exposes it as `Option>`. + let body = response + .into_body() + .and_then(|b| String::from_utf8(b).ok()) + .unwrap_or_default(); + match status { + 401 | 403 => CliError::Auth(format!( + "WebSocket upgrade rejected with {status}: {}", + truncate(&body, 200), + )), + 404 => CliError::Discovery(format!( + "WebSocket endpoint not found (404): {}", + truncate(&body, 200), + )), + 500..=599 => CliError::Api { + code: status, + message: format!("WebSocket upgrade failed: {}", truncate(&body, 200)), + reason: "wsHandshakeServerError".into(), + }, + _ => CliError::Other(anyhow::anyhow!( + "WebSocket upgrade failed with status {status}: {}", + truncate(&body, 200), + )), + } + } + TE::Url(e) => { + // tungstenite couldn't even parse / route the URL — caller + // gave us garbage. + CliError::Validation(format!("invalid WebSocket URL: {e}")) + } + // Everything else (Io, Tls, ConnectionClosed before negotiation, + // protocol violations during the upgrade) is transport-shaped. + other => CliError::Other(anyhow::anyhow!("WebSocket handshake failed: {other}")), + } +} + +/// Map a `tungstenite::Error` raised mid-stream (after handshake) to a +/// [`CliError`]. Always returns an `Err`; the recv loop maps `Ok` paths +/// (clean close 1000, polite close 1001) directly. `hint` is the message +/// the user should investigate — pass the WS config's +/// [`super::WsConfig::abnormal_close_hint`] (or the default). +pub(crate) fn map_stream_error(err: tungstenite::Error, hint: &str) -> CliError { + use tungstenite::Error as TE; + + match err { + TE::ConnectionClosed | TE::AlreadyClosed => CliError::Other(anyhow::anyhow!( + "WebSocket connection closed unexpectedly — {hint}" + )), + TE::Io(e) => CliError::Other(anyhow::anyhow!( + "WebSocket I/O error mid-stream: {e} — {hint}" + )), + other => CliError::Other(anyhow::anyhow!( + "WebSocket protocol error mid-stream: {other}" + )), + } +} + +/// Classify a server-initiated close frame. +/// +/// Returns `Ok(())` for **success-shaped** closures: +/// - `1000 Normal Closure` — protocol-correct end-of-session. +/// - `1001 Going Away` — peer is leaving (page navigation, server +/// shutdown, *or* session-cap expiry like OpenAI Realtime's 30-minute +/// hard limit). For long-running sessions this is the polite way to +/// say "we're done"; treating it as an error would cause shell +/// pipelines to spuriously fail on a clean end-of-session. +/// +/// Returns `Err` for everything else, with `hint` woven into the message +/// when supplied. `hint` is what the user should investigate; pass +/// [`ABNORMAL_CLOSE_HINT`] for the API-neutral default, or supply an +/// API-specific string (see [`super::WsConfig::abnormal_close_hint`]). +pub(crate) fn classify_close_frame( + frame: Option<&tungstenite::protocol::CloseFrame<'_>>, + hint: &str, +) -> Result<(), CliError> { + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + + let Some(frame) = frame else { + // No close frame at all — the peer just hung up. Treat as abnormal. + return Err(CliError::Other(anyhow::anyhow!( + "WebSocket peer closed without a close frame — {hint}" + ))); + }; + match frame.code { + CloseCode::Normal => Ok(()), + CloseCode::Away => { + // 1001 "Going Away" — log to stderr so the user sees that + // the session ended for a benign reason, but don't fail the + // exit code. + let reason_suffix = if frame.reason.is_empty() { + String::new() + } else { + format!(" ({})", frame.reason) + }; + eprintln!( + "websocket: session ended with code 1001 going away{reason_suffix}" + ); + Ok(()) + } + _ => { + let code: u16 = frame.code.into(); + Err(CliError::Other(anyhow::anyhow!( + "WebSocket closed with code {code}{} — {hint}", + if frame.reason.is_empty() { + String::new() + } else { + format!(" ({})", frame.reason) + }, + ))) + } + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + // Truncate on a char boundary for safety; the body may be UTF-8 + // and slicing in the middle of a multibyte sequence panics. + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &s[..end]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio_tungstenite::tungstenite::protocol::{CloseFrame, frame::coding::CloseCode}; + use std::borrow::Cow; + + fn frame(code: u16, reason: &'static str) -> CloseFrame<'static> { + CloseFrame { + code: CloseCode::from(code), + reason: Cow::Borrowed(reason), + } + } + + #[test] + fn close_1000_is_ok() { + assert!(classify_close_frame(Some(&frame(1000, "")), ABNORMAL_CLOSE_HINT).is_ok()); + } + + #[test] + fn close_1001_going_away_is_ok() { + // 1001 = peer is leaving (page nav, server shutdown, session-cap + // expiry). Treated as a clean end-of-session per OpenAI Realtime + // 30-minute hard limit and similar "polite hangup" patterns. + assert!(classify_close_frame(Some(&frame(1001, "session cap")), ABNORMAL_CLOSE_HINT).is_ok()); + } + + #[test] + fn close_1006_is_err_with_hint() { + let err = classify_close_frame(Some(&frame(1006, "")), ABNORMAL_CLOSE_HINT).unwrap_err(); + assert!(err.to_string().contains("1006")); + assert!(err.to_string().contains(ABNORMAL_CLOSE_HINT)); + } + + #[test] + fn close_with_reason_includes_reason_in_message() { + let err = classify_close_frame(Some(&frame(1011, "internal error")), ABNORMAL_CLOSE_HINT) + .unwrap_err(); + assert!(err.to_string().contains("internal error")); + } + + #[test] + fn missing_close_frame_is_abnormal_err() { + let err = classify_close_frame(None, ABNORMAL_CLOSE_HINT).unwrap_err(); + assert!(err.to_string().contains(ABNORMAL_CLOSE_HINT)); + } + + #[test] + fn custom_hint_replaces_default_in_message() { + let custom = "Deepgram check: KeepAlive cadence + audio format"; + let err = classify_close_frame(Some(&frame(1006, "")), custom).unwrap_err(); + assert!(err.to_string().contains(custom)); + assert!(!err.to_string().contains(ABNORMAL_CLOSE_HINT), + "default hint should NOT appear when a custom one was passed"); + } + + #[test] + fn handshake_url_error_maps_to_validation() { + let err = map_handshake_error(tungstenite::Error::Url( + tungstenite::error::UrlError::NoHostName, + )); + assert!(matches!(err, CliError::Validation(_))); + } + + #[test] + fn truncate_respects_char_boundary() { + // U+1F600 is 4 bytes in UTF-8. Truncating at byte 2 would split it. + let s = "ab😀cd"; + let truncated = truncate(s, 3); + // Should fall back to a char boundary at or before 3. + assert!(truncated.starts_with("ab")); + } +} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs new file mode 100644 index 0000000..1bb93bd --- /dev/null +++ b/src/websocket/mod.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! WebSocket bidirectional client. +//! +//! Used by the AsyncAPI binding (`src/asyncapi/`) and by custom commands +//! that need to graft a long-lived bidirectional connection onto the +//! CLI. The recv loop emits each inbound JSON frame through +//! [`crate::formatter::OutputPipeline`] so format / color / future +//! jq/fields/template flags compose for free. +//! +//! # Composition with [`AppContext`](crate::openapi::AppContext) +//! +//! Custom-command handlers are synchronous, but the WS client is async. +//! Bridge with the same `block_in_place` + `Handle::current().block_on(...)` +//! pattern that [`AppContext::execute`](crate::openapi::AppContext::execute) +//! uses internally — see [`WebSocketClient::connect`] for an example. +//! +//! # Auth +//! +//! `WsAuth::{QueryParam, Header, FirstMessage}` each take an +//! [`AuthCredentialSource`](crate::auth::AuthCredentialSource) directly — +//! the WS module does **not** call into [`AuthProvider`](crate::auth::AuthProvider) +//! because that surface is reqwest-shaped. See +//! `docs/adr/0001-auth-provider-no-cred-extraction.md`. +//! +//! # TLS +//! +//! `WebSocketClient::connect` honors compile-time roots from +//! `CliApp::extra_root_cert` and resolves the same env vars as the +//! reqwest path via [`HttpConfig::resolve`](crate::http::HttpConfig::resolve) +//! — `_CA_BUNDLE`, `_INSECURE`, `_CONNECT_TIMEOUT_SECS`. +//! Proxy support (`_PROXY`) is not implemented in v1; document it as +//! a follow-up. +//! +//! # Graceful shutdown +//! +//! [`WebSocketClient::run_until_shutdown`] takes any future. Production +//! wires it to [`tokio::signal::ctrl_c`] via the convenience wrapper +//! [`WebSocketClient::run_recv_loop`]; tests wire it to a `oneshot` +//! receiver. + +mod auth; +mod client; +mod error; + +pub use auth::WsAuth; +pub use client::{AutoResponder, ResponderAction, WebSocketClient, WsConfig}; +pub use error::map_handshake_error;